🔑 重要なポイント
✦ AI·GENDocker in Docker(DinD)に Caddy を重ねた環境で、PostHog Data Pipeline をローカルに立てるまでの記録。ODBC ドライバと証明書の問題、マイクロサービス間でホスト名が解決できない罠、タスクキューを監視する Worker が欠けていて Temporal タスクが永遠に止まった顛末、MinIO を騙すための偽の AWS 認証情報、そして Full Sync をハングと勘違いして一時間ログを睨み続けた話まで——データ同期からデータレイクまでを一通り追ったデバッグ記録。
最近、会社でデータ同期の要件にぶつかりました。発端は、フロントエンドがログイン処理でユーザーの部署(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 と設定を開くと、案の定何かが足りない。この二行を足して再起動:
TEMPORAL_HOST=temporal
TEMPORAL_PORT=7233もう一度 curl temporal:7233——通った。ログの中のタスクも無事に投入され、ステータスはついに Running になった。
消えた Worker と、ハードコアなデバッグ実録#
ステータスは Running になったのに、タスクはブラックホールに落ちたように、まったく進まない。UI には Member / Dept の同期が一列、止まっているか、はっきり Failed。クリックして中を見ても、わけの分からない TypeError が一つ出るだけで、どこで死んだのか見当もつかない。
漠然としたエラーからは何も引き出せないので、直接 を叩きにいって、どの段階で詰まっているのか確かめることにした。まず、状態が Running のワークフローを一覧する:
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(つまり DB パイプライン)の起動要求をちゃんと受け取った。 - 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 の環境変数でちょっとした「偽装術」を使う——名前だけは AWS に合わせ、実体はローカルの MinIO を指す偽の認証情報を差し込む:
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 ファイルが一列また一列と生えてくるのを眺めて、データはやっとレイクへ落ち着いた。
小話:ログを睨んで人生を疑った一時間#
すべての設定が揃い、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 の同期を発火させると、データの実際の道筋はこうなる:
- 抽出と変換(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 ファイルをそのまま外部テーブル(External Tables)としてマップする。
- フロント表示(UI & Dashboard):ユーザーが PostHog UI でグラフを引いたり、
deptでユーザーイベントを絞りたいとき、ClickHouse はものすごい速さで、ローカルのイベントデータ(Events)と MinIO へ同期したばかりの member/dept データを JOIN し、結果を Web API へ返す。
最外層の Caddy、あの愛憎入り混じる DinD、そして中のデータレイク流水線をまとめて一枚に描くと、だいたいこうなる:
週末まるまる格闘して得た一番の学びはこれだ:マイクロサービスの「疎結合」は、本当に諸刃の剣だということ。そこへ会社のような Caddy と DinD を重ねた複雑なインフラが加わると、調査コストは一直線に跳ね上がる——たった一つの環境変数の欠落、一つの下層ドライバの既定の格上げ、誰も監視していないキュー一本、そのどれか一本のネジが緩むだけで、パイプライン全体が、まともなエラーすら吐かずに、静かに止まってしまう。
まだコメントがありません
✨ 最初のコメントを残しませんか