最近在公司遇到一个资料同步的需求。起因是前端在处理用户登入时,一直没有正确取得到用户的部门(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 静静地停摆,还不喷一个像样的错误。