🔑 關鍵洞察
✦ AI·GEN這篇文章記錄了作者在本地部署 PostHog Data Pipeline 時的技術挑戰與解決過程。文章詳細描述了在 Docker in Docker (DinD) 和 Caddy 環境下,如何處理 ODBC 驅動憑證問題、微服務間的網路通訊錯誤,以及 Temporal 任務因佇列沒人監聽而卡住的排查過程。作者還分享了如何新增專屬 Worker 處理資料倉儲任務,並用「偽裝」的 AWS 憑證解決 MinIO 相容性問題。整個過程展示了在複雜架構下,從資料同步到資料湖的完整除錯經驗。
最近在公司遇到一個資料同步的需求。起因是前端在處理用戶登入時,一直沒有正確取得到用戶的部門(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 和配置,果然少了東西。補上這兩行後重啟:
TEMPORAL_HOST=temporal
TEMPORAL_PORT=7233再次 curl temporal:7233,通了。日誌裡的任務也順利提交,狀態終於變成了 Running。
消失的 Worker 與硬核除錯實錄#
雖然狀態變成了 Running,但任務就像掉進了黑洞,完全沒有進展。UI 上一排 Member / Dept 的同步不是卡著就是直接 Failed,點進去也只有一個看不出所以然的 TypeError,完全不知道死在哪。
籠統的錯誤問不出東西,我決定直接去翻 ,看到底是哪個環節卡住。首先,列出目前狀態是 Running 的 Workflow:
docker exec -it deploy-temporal-admin-tools-1 temporal workflow list \
--address temporal:7233 --query "ExecutionStatus='Running'"在印出的表格中,找到了 external-data-job 這個任務,把它的 WorkflowId 複製下來,接著查看它的詳細報錯與執行歷史:
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:
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 容器,看了下啟動腳本的說明:
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 裡:
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 憑證:
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 手動建出來:
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 的同步時,資料的實際流動軌跡是這樣的:
- 萃取與轉換(Extract & Transform):Temporal DW Worker 收到排程後,透過 ODBC 連線進入外部資料庫,把巨量的 member 和 dept 資料撈出來,並在記憶體中轉換成列式存儲()的 Parquet 格式。
- 狀態追蹤(State Tracking):為了確保這種跑上一小時的任務中斷後還能接續,Worker 會不斷把 的進度與游標寫進
CDP_REDIS_HOST指定的 Redis。 - 寫入資料湖(Load to Data Lake):轉換好的資料不會進 Postgres,而是被打包成一堆
.parquet檔案,透過我們剛剛偽裝好的 S3 API,直接砸進 MinIO 的data-warehouseBucket。 - 查詢引擎介入(Query Engine):真正厲害的來了——PostHog 底層的高效能分析引擎 會直接把 MinIO 裡的這些 Parquet 檔案 Map 成外部資料表(External Tables)。
- 前端展現(UI & Dashboard):當使用者在 PostHog UI 上拉圖表、或想依
dept篩選使用者事件時,ClickHouse 就以極快的速度,把本地的事件資料(Events)與 MinIO 裡剛同步過來的 member/dept 資料做 JOIN,再把結果吐回給 Web API。
把最外層的 Caddy、那層讓人又愛又恨的 DinD、以及裡頭這條資料湖流水線全部畫在一起,大概長這樣:
折騰了一整個週末,最大的體悟是:微服務的「解耦」真的是把雙刃劍。加上公司那種套了 Caddy 和 DinD 的複雜基礎設施,讓排查成本直線上升——一個環境變數的缺失、一個底層驅動的默認升級、一條沒人監聽的佇列,任何一個小螺絲鬆了,就能讓整條 Pipeline 靜靜地停擺,還不噴一個像樣的錯誤。
還沒有留言
✨ 成為第一個留言的人吧