最近、会社でデータ同期の要件にぶつかりました。発端は、フロントエンドがログイン処理でユーザーの部署(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 は既定で を入れていて、Microsoft はこの 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 のワークフローを一覧する:

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(つまり DB パイプライン)の起動要求をちゃんと受け取った。
  • 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 enabledNoSuchBucket が出た。

PostHog は内部で ライブラリでデータを書き込むが、こいつは AWS 認証情報しか認めない頑固がんこ者。うちはオープンソースの MinIO なので、Worker の環境変数でちょっとした「偽装ぎそう術」を使う——名前だけは AWS に合わせ、実体はローカルの MinIO を指す偽の認証情報を差し込む:

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 ファイルが一列また一列と生えてくるのを眺めて、データはやっとレイクへ落ち着いた。

小話:ログを睨んで人生を疑った一時間#

すべての設定が揃い、Bucket も作り終え、私は自信満々に UI の "Sync" を押した。

タスクは無事に起動し、Temporal Worker は狂ったようにログを吐き始めた。最初はデータが一行また一行と取り込まれるのを眺めて癒されもしたが、十分、二十分、三十分……ログは果てしなくスクロールし続け、ステータスは相変わらず Running

外側に DinD と Caddy が被さっていてネットワークが込み入っているぶん、私はどこかで TCP タイムアウトが踏まれたのでは、あるいは Worker がひっそり してどこかのループで固まったのでは、と疑った。もう一つ端末を開いて、Docker daemon 全体を再起動する準備までしていた。

だが真相は、ずっと docker stats に書いてあった:

そうして小一時間、ひやひやしながら端末をにらみ続けた末、突然一行が飛び出した:Sync completed successfully。そこでハッと気づいた:そうだ、これは Full Sync だ。会社が何年も溜め込んだ member と dept の履歴はあまりに膨大で、Worker はただ黙々もくもくと、そのデータをチャンクに切り(chunking)、形式を変換し、MinIO へ書き込んでいただけ。完全に自分で自分を怖がっていた。

核心を解剖:この構成で、データは結局どう流れるのか#

DinD と Caddy に包まれたこのデータの流れは、確かに少し込み入っている。PostHog の Data Warehouse の設計思想は、外部データを自分のリレーショナル DB へ単純に「複製」するのではなく、現代的な アーキテクチャを採る。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 ファイルをそのまま外部テーブル(External Tables)としてマップする。
  5. フロント表示(UI & Dashboard):ユーザーが PostHog UI でグラフを引いたり、dept でユーザーイベントを絞りたいとき、ClickHouse はものすごい速さで、ローカルのイベントデータ(Events)と MinIO へ同期したばかりの member/dept データを JOIN し、結果を Web API へ返す。

最外層の Caddy、あの愛憎入り混じる DinD、そして中のデータレイク流水線をまとめて一枚に描くと、だいたいこうなる:

週末まるまる格闘して得た一番の学びはこれだ:マイクロサービスの「疎結合」は、本当に諸刃の剣だということ。そこへ会社のような Caddy と DinD を重ねた複雑なインフラが加わると、調査コストは一直線に跳ね上がる——たった一つの環境変数の欠落、一つの下層ドライバの既定の格上げ、誰も監視していないキュー一本、そのどれか一本のネジが緩むだけで、パイプライン全体が、まともなエラーすら吐かずに、静かに止まってしまう。