はじめに

ローカルの Linux サーバー上で DagsterTrinoDataFusionPostgreSQL を組み合わせた ELT 基盤を組むと、ボトルネックは単純な CPU 性能ではなく、どのステップにどれだけメモリを使わせ、いつ解放させるかに集約されることが多い。今回のメモでは、AMD EPYC 9175F 16c/32t768GB DDR5-6400RTX 6000 Pro Max-QNVMe 3.84TB という前提の上で、512GB を ELT 専用プールとして切り出し、それをどう循環利用するかを整理した。

この種のデータ基盤を 48h Prototypeデータ基盤整備 の提供形に接続する構想もあり、単なるチューニング断片ではなく、Airbyte -> Iceberg / Trino -> 可視化 のような一連の流れをローカル・オフライン重視で回すための運転ルールとして位置付けている。

背景・動機

最初に決めたかったのは、「大きなメモリを積んでいるのだから、とにかく全部メモリで押し切ればよい」という雑な設計を避けることだった。768GB 積んでいても、Trino の heavy query、DataFusion の大きな変換、PostgreSQL のロード、Dagster のワーカー並列が同時に走れば、簡単にピークがぶつかる。しかも一度ピークが重なると、スワップや OOM の問題だけではなく、復旧しにくい中途半端な失敗になりやすい。

そこで中心方針を次の一文にまとめた。

512GB をステップごとに循環利用し、軽いときは In-Memory で最速、重いときは Arrow IPCParquet へ安全に逃がす。

この方針にすると、速さだけでなく、障害時の再開性や運用ルールまで一緒に設計できる。

基本リソース前提

前提リソースは次の通り。

  • サーバー: AMD EPYC 9175F 16c/32t (5GHz) + 768GB DDR5-6400 + RTX 6000 Pro Max-Q + NVMe 3.84TB
  • ELT プール: 512GB をワークフロー専用に確保
  • 帯域: メモリ帯域 ~600GB/s、NVMe I/O 数 GB/s、ネットワーク 10GbE

ここで重要なのは、512GB を「システム全体で自由に食ってよい上限」として扱わず、「その時点で実行中の ELT ステップに貸し出す運転資源」として扱うことだ。データ基盤や可視化、MLOps の構成を広げるほど、個々のサービスを常時フル稼働前提で置くより、ワークロードごとに明確な枠を切った方が保守しやすい。

メモリ運用ポリシー

原則

メモリ運用の原則はかなり明確だ。

  • 512GB プールをステップごとに循環利用する
  • 同一プロセスなら In-Memory 直渡しを優先する
  • 別プロセスやコンテナに分ける場合は、合算で 512GB 上限に収める

Dagster でワークフローを組むと、つい op を細かく並べて並列で回したくなるが、重い分析系ではそれが逆効果になりやすい。むしろ、1 本ずつ太く流し、終わったら確実に解放し、次のステップが同じメモリを再利用する方が安定する。

切替基準

中間サイズごとの切替基準も定義している。

中間サイズ推奨方式備考
<= 40-50% (200-250GB)In-Memory(同一プロセス + InMemoryIOManager帯域 600GB/s を直接使えるので最速
50-80%Arrow IPC ストリームNVMe 退避でもオーバーヘッドを抑えやすい
>80% または複数 op 同時Parquet 一時出力失敗時再開や安全性を優先

この境界を明文化しておくと、「今回はメモリに乗せるべきか」「最初から Parquet に落とすべきか」をその場の感覚ではなくルールで判断できる。

各コンポーネントの最適化

DataFusion (Rust)

DataFusion 側では、巨大な中間データを不用意に collect しないことを最優先にしている。

  • MemoryPool 上限を op 単位で S/M/L プロファイルとして設定
  • DiskManager は NVMe の /opt/tmp を指定し、必要なら強制スピル可能にする
  • Streaming APIexecute_stream() を優先する
  • パーティション数は実コア数である 16c に合わせる

ここで大事なのは、メモリをたくさん積んでいても「全部集める」設計にしないことだ。ストリームで流し切れる処理は流し切る方が、結果的にピークを低く抑えられる。

Trino

Trino は heavy query を走らせると簡単にノード全体のメモリ圧迫要因になるので、設定だけでなく実行ルールもセットで必要だ。

  JVM: -Xmx192g
max-spill-per-node=512GB
query.max-memory-per-node=128GB
query.max-total-memory-per-node=160GB
  

この前提では 256GB コンテナに収め、spill は NVMe に逃がす。さらに heavy query は Dagster 側の concurrency 制御で同時 1 本に絞る。クエリエンジン単体の設定だけではなく、オーケストレータ側で「重い仕事を同時に投げない」ことが効く。

PostgreSQL

PostgreSQL は ELT 全体の中では「最後の永続化」と「大量ロード」の役割が大きいので、派手なチューニングより確実なロード手順が重要だ。

  shared_buffers=8-16GB
work_mem=128MB
maintenance_work_mem=2-4GB
wal_compression=lz4
checkpoint_timeout=20-30min
max_wal_size=12-16GB
  

大量ロード時は次のどちらかを基本形にする。

  • UNLOGGED TABLE -> COPY -> インデックス作成 -> LOGGED
  • CTAS (CREATE TABLE AS SELECT)

必要に応じて、取り込み中だけ synchronous_commit=off を使う選択肢も残す。常時無効化ではなく、取り込みフェーズ限定の判断に留めるのが現実的だ。

Dagster

Dagster では executor を仕事の重さに応じて切り替えるのが中核だ。

  • 軽量: in_process_executor + InMemoryIOManager
  • 重量: multiprocess_executor + opごとに Podman コンテナ (--memory/--cpuset-cpus)

さらにタグで concurrency を制御する。

  • heavy 系 (Trino / DataFusion 大 op): max_concurrent=1
  • 軽い op(メタ処理、dbt run、ログ): 並列可

これを決めておくと、ワークフローを書いた人ごとに実行ポリシーがぶれるのを防げる。

プロセス / コンテナ設計

In-Memory(最速)

一番速いのは、同一ジョブを in-process executor で実行し、中間を pyarrow.TableRecordBatch の shallow copy で渡す形だ。条件は、中間サイズが <=200GB 程度、つまり ELT プールの 50% 以下に収まること。

この条件を外れるのに無理にメモリ直渡しを続けると、後続 op の起動時に一気に危なくなる。

コンテナ分離(安定)

安定性重視なら、各 op を podman run --memory=... --cpuset-cpus=... で分離して実行する。終了すればそのプロセス空間ごと解放されるので、512GB を循環利用するという方針に最も素直だ。

Podman を主軸にした運用や、ローカル環境での再現性確保が強く意識されているので、この方式は全体方針とも相性が良い。

subprocess 起動時の注意

Python 親プロセスから Rust 子プロセスを起動する場合は、親が巨大バッファを持ったままだと合算ピークで OOM を起こしやすくなる。対策としては次の通り。

  • 子起動前に親のバッファを del
  • スコープを抜ける
  • gc.collect() を呼ぶ
  • Python は fork より spawn を推奨する

fork の Copy-on-Write が破れると、実メモリが思った以上に増えるので、この注意点は地味だが重要だ。

I/O 最適化

I/O は単に「速いディスクを使う」では足りず、中間データの表現と置き場所を決める必要がある。

  • Arrow IPC: 最速退避。ゼロコピー寄り
  • Parquet: 安全性とリトライ性重視。圧縮は zstd(1-3)、行グループは 128-256MB
  • NVMe: スピルディレクトリを /opt/tmp に集約
  • /dev/shm: Arrow Plasma / IPC なら --shm-size 2-4GB
  • クリーンアップ: ジョブ終了時に一時ファイルを削除し、失敗時もハンドルする

Arrow IPC は速さが欲しい時の逃がし先、Parquet は確実さが欲しい時の逃がし先、という役割分担にしておくと迷わない。

運転ルール

最終的な運転ルールは次の 5 点に収束した。

  1. 重いクエリは 1 本ずつ太く回す
  2. op ごとに終了させて解放し、512GB を循環利用する
  3. 中間サイズに応じて In-Mem -> Arrow IPC -> Parquet を切り替える
  4. 障害許容性が必要な処理は Parquet を選ぶ
  5. 速度最優先の処理は In-Memory で shallow copy / ポインタ渡しを使う

このあたりは個別最適に見えて、実際にはチーム運用のためのルールでもある。どの job でも同じ判断軸で設計できるようにするのが狙いだ。

メリット

この設計のメリットは大きく 4 つある。

  • 速さ: In-Memory でメモリ帯域 600GB/s を最大限に使える
  • 安定性: 512GB 循環利用と cgroup 制御、spill で落ちにくい
  • 柔軟性: 中間サイズに応じて Arrow IPC / Parquet を切り替えられる
  • シンプル性: Dagster の executor 切替で運転モードを変えられる

単機サーバー運用では、「何でも並列化する」より「どこまでを同時に許すか」を明示した方が結果的に速く、壊れにくくなる。

まとめ

512GB をステップごとに循環利用し、軽いときは In-Memory で高速化し、重いときは Arrow / Parquet に退避し、op はコンテナ分離で確実に解放する。

今後の展開

この方針をそのまま podman-compose.yml のメモリ / CPU プロファイルと Dagster executor 設定に落とし込めば、実運用向けの雛形にできる。Airbyte OSSGreat ExpectationsOpenMetadataMLflow なども含めたローカル重視のデータ基盤像が整理されていたので、次はこの ELT 実行ルールを周辺サービスまで含めたテンプレートにするのが自然だと思っている。