最近在公司遇到一個資料同步的需求。起因是前端在處理用戶登入時,一直沒有正確取得到用戶的部門(dept)名稱資料。於是 DBA 提出了一個解法:希望能直接從底層出手,透過資料管道把資料庫裡現有的 member 資料撈出來 sync,順便把這段缺失的 dept 資訊給補上。為了解決這個需求,我們決定在本地端架設一套 PostHog Data Pipeline。

不過,這套系統跑在一個有點神奇的工作環境裡。我們的基礎服務全都是架設在 內部的,並且最外層套了一個 來做流量導向。不要問我為什麼要套這麼多層,我也不是很清楚。大概是因為我們公司對外使用的是 ISP 提供的固定 IP,為了要在同一個實體固定 IP 上,透過不同的 DNS 域名來做服務路由分發,所以才糊了這層 Caddy 上去。

總之,在這種 DinD 疊 Caddy 的環境下跑一整套包含 的微服務,原本以為只要把官方的 Docker Compose 抄過來、跑起來、接上 DB 就能完事。結果,這才是一場排查惡夢的開始。

ODBC 驅動與憑證問題#

一切的起點,是 PostHog UI 上的資料庫同步任務直接報了個冷冰冰的「連線錯誤」。沒有詳細日誌,沒有錯誤堆疊。

我用 curl 和 Python 的 pymssql 鑽進容器裡測試,發現 TCP 網路跟直連都通暢無比。但當我切換到 PostHog 底層依賴的 時,卻丟出了一個 file not found

查了一下容器內的配置(/etc/odbcinst.ini),破案了。PostHog 的 Image 默認安裝了 。微軟在 Driver 18 做了個很激進的改動:預設強制啟用加密連線(Encrypt=yes)。在我們這種沒有配置受信任 TLS 憑證的本地開發環境,連線當然會被直接拒絕。

TIP

解法很直接:在 UI 的進階連線字串裡硬塞一個 TrustServerCertificate=yes,強制驅動信任伺服器憑證,連線瞬間就通了。這也是很多人從 Driver 17 升到 18 時最常踩的坑——同一組設定,昨天還好好的,升個驅動就連不上。

迷失在微服務裡的網路請求#

資料庫連上了,但我按下 "Reload" 後,任務卻毫無反應。

我懷疑是 Django API(Web 容器)跟 Temporal Server 之間的通訊斷了。進去一 ping:ping: temporal: Name or service not known。Web 容器根本不認識誰是 temporal。這在 Docker Compose 的世界裡很致命,因為如果沒有正確注入環境變數,應用程式只會傻傻地去找 localhost

翻開 .env 和配置,果然少了東西。補上這兩行後重啟:

env
TEMPORAL_HOST=temporal
TEMPORAL_PORT=7233

再次 curl temporal:7233,通了。日誌裡的任務也順利提交,狀態終於變成了 Running

消失的 Worker 與硬核除錯實錄#

雖然狀態變成了 Running,但任務就像掉進了黑洞,完全沒有進展。UI 上一排 Member / Dept 的同步不是卡著就是直接 Failed,點進去也只有一個看不出所以然的 TypeError,完全不知道死在哪。

籠統的錯誤問不出東西,我決定直接去翻 ,看到底是哪個環節卡住。首先,列出目前狀態是 Running 的 Workflow:

bash
docker exec -it deploy-temporal-admin-tools-1 temporal workflow list \
  --address temporal:7233 --query "ExecutionStatus='Running'"

在印出的表格中,找到了 external-data-job 這個任務,把它的 WorkflowId 複製下來,接著查看它的詳細報錯與執行歷史:

text
temporal workflow show --address temporal:7233 -w external-data-job(...)

  ID          Time                    Type
   1  2026-02-23T09:56:10Z  WorkflowExecutionStarted
   2  2026-02-23T09:56:10Z  WorkflowTaskScheduled

看懂這兩行日誌,是破案的關鍵:

  • Started:Temporal 伺服器成功收到了啟動 external-data-job(也就是資料庫 Pipeline)的請求。
  • Scheduled:Temporal 把這個任務放進了「任務佇列(Task Queue)」,正在等待 Worker 來把它接走。

問題就在這:後面沒了。 正常來說,下一條應該立刻緊接著 WorkflowTaskStarted(代表 Worker 開始處理)。這說明了一件事——根本沒有 Worker 來接手這個任務。

為了確認它到底在等哪一個佇列,我補上 -o json 並過濾 taskqueue:

bash
temporal workflow show ... -o json | grep -i "taskqueue"

# 輸出結果:
# TaskQueue:{Name:data-warehouse-task-queue, Kind:Normal}

IMPORTANT

真相大白!任務被派發到了 data-warehouse-task-queue,但回頭看環境裡那個 Worker 的啟動日誌,它只監聽 general-purpose-task-queue。原來這份配置(看起來是官方的 hobby 個人版預設檔)天生就少了一個專門處理資料倉儲任務的 Worker——任務被丟進一個沒人在聽的佇列,自然永遠卡在 Scheduled。

把這個「佇列沒人接」的死局畫出來,就一目瞭然:

既然知道缺什麼,那就自己補。進入現有的 Worker 容器,看了下啟動腳本的說明:

bash
docker exec -it deploy-temporal-django-worker-1 \
  python manage.py start_temporal_worker --help

#  --task-queue TASK_QUEUE
#                        Task queue to service

只要在啟動指令加上 --task-queue data-warehouse-task-queue 就可以了。

小插曲:關於修改 Config 的無奈#

找到了解法,接下來只要修改 docker-compose.yml 把這個專屬 Worker 加進去就好。但這裡必須先吐槽一下。

總之,我把這段全新的服務設定補進了 docker-compose.yml 裡:

yaml
temporal-data-warehouse-worker:
    extends:
        file: docker-compose.base.yml
        service: temporal-django-worker
    command: python manage.py start_temporal_worker --task-queue data-warehouse-task-queue
    environment:
        SITE_URL: https://$DOMAIN
        # 下面這幾個是後來補上的大坑:
        ENCRYPTION_SALT_KEYS: ${ENCRYPTION_SALT_KEYS}
        CDP_REDIS_HOST: redis
        REDIS_URL: redis://posthog-redis:6379
    depends_on:
        - db
        - redis
        - clickhouse
        - kafka
        - temporal

把這個專屬 Worker 跑起來之後,Pipeline 終於順利啟動了。

偽裝的藝術:MinIO 與 S3 憑證#

經歷了前面的一連串折騰,終於能把 member 和 dept 的資料撈出來了。但在最後一步寫入 MinIO 資料湖時,又報了 credential provider was not enabled 以及 NoSuchBucket

PostHog 底層用 庫來寫入資料,這傢伙是認 AWS 憑證的。我們用的是開源的 MinIO,所以得在 Worker 的環境變數裡玩點「偽裝術」——塞一組名字對得上、但其實指向本地 MinIO 的假 AWS 憑證:

yaml
environment:
  - AWS_ACCESS_KEY_ID=minioadmin
  - AWS_SECRET_ACCESS_KEY=minioadmin
  - AWS_REGION=us-east-1
  - AWS_S3_ENDPOINT=http://minio:9000

接著,寫個簡單的 Boto3 腳本,進容器把缺失的 data-warehouse Bucket 手動建出來:

python
import boto3

s3 = boto3.client(
    's3',
    endpoint_url='http://minio:9000',
    aws_access_key_id='minioadmin',
    aws_secret_access_key='minioadmin'
)

s3.create_bucket(Bucket="data-warehouse")

NOTE

這裡的重點不在於憑證是不是真的,而是變數名對得上deltalake 只是死板地照 AWS 的規矩找 AWS_* 這幾個環境變數,找到了就乖乖把資料寫進我們指定的 endpoint(本地 MinIO)。所謂「偽裝」,偽裝的其實是介面,不是身分。

看著 MinIO 後台終於長出一排排高效的 Parquet 檔案,資料終於順利躺進了資料湖。

小插曲:盯著 Log 懷疑人生的那一小時#

當所有的配置都補齊,Bucket 也建好了,我充滿自信地在 UI 按下 "Sync"。

任務順利啟動,Temporal Worker 開始瘋狂吐 Log。剛開始看著資料一行行被抓取還挺療癒的,但過了十分鐘、二十分鐘、半小時……Log 還在無止盡地滾動,任務狀態依然是 Running

因為外層包了 DinD 和 Caddy,網路環境相對複雜,我一度以為是不是哪裡的 TCP Timeout 被觸發了,或者是 Worker 默默 導致任務卡死在某個迴圈裡。我甚至已經打開了另一個終端機,準備重啟整個 Docker daemon。

而真相,其實一直寫在 docker stats 上:

結果就這樣心驚膽跳地盯著終端機看快一個小時,最後突然跳出一行 Sync completed successfully。這才猛然驚覺:對吼,這是一次 Full Sync。公司這麼多年累積下來的 member 和 dept 歷史資料量實在太龐大了,Worker 只是在老老實實地把這些資料切塊(chunking)、轉換格式並寫入 MinIO 而已。完全是我自己嚇自己。

核心剖析:在這套架構下,資料到底怎麼流動?#

這整套在 DinD 和 Caddy 包裝下的資料流向其實有點繞。PostHog 的 Data Warehouse 設計理念,並不是單純把外部資料「複製」到自己的關聯式資料庫裡,而是走現代的 架構。當我們觸發 member 和 dept 的同步時,資料的實際流動軌跡是這樣的:

  1. 萃取與轉換(Extract & Transform):Temporal DW Worker 收到排程後,透過 ODBC 連線進入外部資料庫,把巨量的 member 和 dept 資料撈出來,並在記憶體中轉換成列式存儲()的 Parquet 格式。
  2. 狀態追蹤(State Tracking):為了確保這種跑上一小時的任務中斷後還能接續,Worker 會不斷把 的進度與游標寫進 CDP_REDIS_HOST 指定的 Redis。
  3. 寫入資料湖(Load to Data Lake):轉換好的資料不會進 Postgres,而是被打包成一堆 .parquet 檔案,透過我們剛剛偽裝好的 S3 API,直接砸進 MinIO 的 data-warehouse Bucket。
  4. 查詢引擎介入(Query Engine):真正厲害的來了——PostHog 底層的高效能分析引擎 會直接把 MinIO 裡的這些 Parquet 檔案 Map 成外部資料表(External Tables)。
  5. 前端展現(UI & Dashboard):當使用者在 PostHog UI 上拉圖表、或想依 dept 篩選使用者事件時,ClickHouse 就以極快的速度,把本地的事件資料(Events)與 MinIO 裡剛同步過來的 member/dept 資料做 JOIN,再把結果吐回給 Web API。

把最外層的 Caddy、那層讓人又愛又恨的 DinD、以及裡頭這條資料湖流水線全部畫在一起,大概長這樣:

折騰了一整個週末,最大的體悟是:微服務的「解耦」真的是把雙刃劍。加上公司那種套了 Caddy 和 DinD 的複雜基礎設施,讓排查成本直線上升——一個環境變數的缺失、一個底層驅動的默認升級、一條沒人監聽的佇列,任何一個小螺絲鬆了,就能讓整條 Pipeline 靜靜地停擺,還不噴一個像樣的錯誤。