Read OSS

起動シーケンス詳解:docker compose up から同期開始まで

中級

前提知識

  • 第1記事:アーキテクチャとコードベースのナビゲーション
  • Engine API とは何か(コンセンサス ↔ 実行レイヤー間の通信プロトコル)
  • JWT 認証の基礎知識

起動シーケンス詳解:docker compose up から同期開始まで

第1回ではコードベース全体を俯瞰し、base/node が Engine API を介して連携する2つのサービスをオーケストレートしていることを理解しました。しかし、アーキテクチャ図はあくまで静的なものです — docker compose up と入力したとき、実際に何が起きているのでしょうか。その答えは、複数のシェルスクリプトにわたる緻密な連携にあります。起動順序の制約はフレームワークではなく、curl ループとプロセスのライフサイクル管理によって強制されています。

この記事では起動シーケンス全体を追っていきます。Docker Compose によるサービス起動から始まり、コンセンサス entrypoint の dispatcher を経て、各クライアントの初期化ロジックまで実行パスをたどります。最終的には、static file manager の初期化を最大6時間待機することもある Reth の注目すべきマルチステージ起動に行き着きます。

Docker Compose の起動順序

docker compose up を実行すると、Docker は docker-compose.yml を読み込み、depends_on のグラフから起動順序を決定します。node サービスは execution への依存を宣言しています。

node:
    depends_on:
      - execution

Docker はまず execution コンテナを起動し、その後 node コンテナを起動します。しかし、ヘルスチェック条件なしの depends_on が待つのは コンテナが作成されるまで であり、内部のアプリケーションが準備完了するまでではありません。実行クライアントが Engine API ポートにバインドするまで、数秒から数分かかることがあります。実際の準備完了の同期は、コンセンサス entrypoint スクリプトが全面的に担っています。

sequenceDiagram
    participant User
    participant DC as Docker Compose
    participant EX as Execution Container
    participant ND as Node Container

    User->>DC: docker compose up
    DC->>EX: Start container
    DC->>EX: Run: bash ./execution-entrypoint
    Note over EX: Execution client initializing...
    DC->>ND: Start container (depends_on satisfied)
    DC->>ND: Run: bash ./consensus-entrypoint
    Note over ND: Polls Engine API with curl
    ND-->>EX: curl http://execution:8551 → connection refused
    Note over ND: sleep 5, retry...
    Note over EX: Engine API ready on :8551
    ND-->>EX: curl http://execution:8551 → HTTP 401
    Note over ND: 401 = authenticated endpoint exists!
    ND->>ND: Write JWT secret, exec consensus binary
    ND-->>EX: Engine API with JWT auth
    Note over EX,ND: Both services syncing

同じイメージを共有しながら2つのコンテナを区別しているのが、docker-compose.yml の command オーバーライドです。execution サービスは bash ./execution-entrypoint13行目)を、node サービスは bash ./consensus-entrypoint33行目)をそれぞれ実行します。すべての Dockerfile がすべての entrypoint スクリプトを /appCOPY するため、両方のファイルが Docker イメージ内に存在しています。

コンセンサス Entrypoint の Dispatcher

consensus-entrypoint は単純ながら重要な dispatcher です。単一の環境変数に基づいて、2つのコンセンサスクライアントのどちらかにルーティングします。

if [ "${USE_BASE_CONSENSUS:-false}" = "true" ]; then
    if [ -f ./base-consensus-entrypoint ]; then
        echo "Using Base Client"
        exec ./base-consensus-entrypoint
    else
        echo "Base client is not supported for this node type"
        exit 1
    fi
else
    echo "Using OP Node"
    exec ./op-node-entrypoint
fi
flowchart TD
    CE[consensus-entrypoint] -->|"USE_BASE_CONSENSUS=true"| CHECK{"base-consensus-entrypoint<br/>exists?"}
    CE -->|"USE_BASE_CONSENSUS=false"| OP[op-node-entrypoint]
    CHECK -->|Yes| BC[base-consensus-entrypoint]
    CHECK -->|No| FAIL["Exit 1:<br/>not supported for this node type"]

5行目のファイル存在チェックは、単なる防御的コーディングではありません。アーキテクチャ上の重要な意味を持ちます。第3回で詳しく説明しますが、base-consensus バイナリとその entrypoint をバンドルしているのは Reth の Dockerfile だけです。CLIENT=geth かつ USE_BASE_CONSENSUS=true と設定した場合、Geth イメージには base-consensus-entrypoint が存在しません。そのため dispatcher は「ファイルが見つかりません」という不明瞭なエラーではなく、わかりやすいエラーメッセージを出してグレースフルに失敗します。

exec の使用に注目してください。シェルプロセスが対象スクリプトに 置き換えられます。これにより、コンセンサス entrypoint の PID が PID 1 になる(または親 PID を継承する)ため、シグナルハンドリングと Docker の停止動作において重要な役割を果たします。

コンセンサスクライアントの起動:Engine API の待機

両方のコンセンサス entrypoint は同じ3フェーズパターンに従っています:環境変数の検証、Engine API の待機、そしてバイナリの exec。それぞれを並べて比較してみましょう。

フェーズ1:環境変数の検証

base-consensus-entrypointBASE_NODE_NETWORK の存在を確認します。

if [[ -z "${BASE_NODE_NETWORK:-}" ]]; then
  echo "expected BASE_NODE_NETWORK to be set" 1>&2
  exit 1
fi

op-node-entrypointOP_NODE_NETWORK または OP_NODE_ROLLUP_CONFIG を確認します。ネットワーク名またはカスタム rollup 設定ファイルのどちらかを指定できる、op-node のより柔軟な設定モデルを反映しています。

フェーズ2:Engine API のポーリング

両スクリプトは、実行クライアントの準備完了を検知するために同じ巧妙なテクニックを使っています。以下は base-consensus-entrypoint の31〜34行目 のポーリングループです。

until [ "$(curl -s --max-time 10 --connect-timeout 5 \
    -w '%{http_code}' -o /dev/null \
    "${BASE_NODE_L2_ENGINE_RPC/ws/http}")" -eq 401 ]; do
  echo "waiting for execution client to be ready"
  sleep 5
done

これは実にエレガントな実装です。Engine API は認証が必要なエンドポイントです。有効な JWT なしにリクエストを送ると、HTTP 401 Unauthorized が返ります。スクリプトはレスポンスボディを気にせず、ステータスコードだけを確認します。401 は Engine API がリッスン中で認証レイヤーが有効であることを証明し、接続拒否やタイムアウトは実行クライアントがまだ準備できていないことを意味します。

${BASE_NODE_L2_ENGINE_RPC/ws/http} の置換に注目してください。Engine API の URL は WebSocket URL(ws://execution:8551)として保存されていますが、curl には HTTP が必要です。この bash パラメータ展開が wshttp にインラインで置き換えています。

フェーズ3:IP 検出、JWT、そして Exec

Engine API の準備完了が確認されると、両スクリプトは P2P アドバタイズ用のノードのパブリック IP を取得します。共有の get_public_ip() 関数が4つのプロバイダ(ifconfig.me、api.ipify.org、ipecho.net、v4.ident.me)を順番に試します。その後、JWT secret を書き込んでバイナリを exec します。

base-consensus-entrypoint には固有の機能があります — follow モードです。

if [[ -n "${BASE_NODE_SOURCE_L2_RPC:-}" ]]; then
  echo "Running base-consensus in follow mode because BASE_NODE_SOURCE_L2_RPC is set"
  exec ./base-consensus follow
else
  exec ./base-consensus node
fi

follow モードを使うと、L1 からのデリベーションではなく、別の L2 ノードの RPC エンドポイントから同期できます。新しいノードを素早くブートストラップしたいときに便利です。

sequenceDiagram
    participant EP as base-consensus-entrypoint
    participant IP as IP Providers
    participant EX as Execution Client
    participant FS as Filesystem
    participant BC as base-consensus

    EP->>EP: Validate BASE_NODE_NETWORK
    loop Until HTTP 401
        EP->>EX: curl http://execution:8551
        EX-->>EP: Connection refused / 401
    end
    EP->>IP: curl ifconfig.me (+ fallbacks)
    IP-->>EP: Public IP
    EP->>FS: Write JWT to /tmp/engine-auth-jwt
    alt SOURCE_L2_RPC set
        EP->>BC: exec ./base-consensus follow
    else
        EP->>BC: exec ./base-consensus node
    end

実行クライアントの起動パターン

実行クライアントの entrypoint はコンセンサスクライアントが接続するに実行されます。JWT secret の書き込み、データディレクトリのセットアップ、適切なフラグでのバイナリ起動が主な役割です。3つのクライアントはそれぞれ異なる方法でこれを処理します。

Geth:細やかなパラメータ調整

geth/geth-entrypoint はそれなりに複雑で、キャッシュチューニング専用の5つの変数があります(18〜22行目)。

GETH_CACHE="${GETH_CACHE:-20480}"
GETH_CACHE_DATABASE="${GETH_CACHE_DATABASE:-20}"
GETH_CACHE_GC="${GETH_CACHE_GC:-12}"
GETH_CACHE_SNAPSHOT="${GETH_CACHE_SNAPSHOT:-24}"
GETH_CACHE_TRIE="${GETH_CACHE_TRIE:-44}"

これらの割合は、Geth が 20GB のキャッシュプールをどのように分配するかを制御します。デフォルトでは trie キャッシングに 44%、スナップショットに 24%、データベースに 20%、ガベージコレクションに 12% を割り当てています。trie 操作が支配的な L2 ワークロードに最適化されたプロファイルです。

Geth はさらに、33〜51行目${VAR+x} 構文で変数が設定されているかどうか(単に空でないかではなく)を確認するパターンを使い、ethstats、unprotected transactions、state scheme、bootnode のフラグを条件付きで追加します。

Nethermind:シンプルを極めた設計

nethermind/nethermind-entrypoint は3つの中で最もシンプルです。最大の特徴は --config フラグです。

exec ./nethermind \
    --config="$OP_NODE_NETWORK" \

Nethermind にはネットワーク設定が組み込まれています。--config=base-mainnet を渡すだけで、genesis、fork の高さ、gas リミットなど、チェーン固有のパラメータがすべて有効になります。追加フラグは不要です。RETH_CHAIN やカスタム初期化ステップが必要ない理由がここにあります。

Reth:複雑な起動経路

Reth の entrypoint は群を抜いて高度で、独立したセクションで説明する価値があります。

Reth の Historical Proofs 初期化

reth/reth-entrypoint はリポジトリ全体で最も複雑なシェルロジックを含んでいます。標準的な起動処理に加えて、ログレベルの変換、Flashblocks サポート、historical proofs の初期化という3つの独自機能を処理します。

ログレベルの変換

Reth は名前付きログレベルではなく、verbosity フラグ(-v-vv-vvv など)を使う慣習を採用しています。entrypoint は 31〜51行目 でその変換を行います。

case "$LOG_LEVEL" in
    "error") LOG_LEVEL="v" ;;
    "warn")  LOG_LEVEL="vv" ;;
    "info")  LOG_LEVEL="vvv" ;;
    "debug") LOG_LEVEL="vvvv" ;;
    "trace") LOG_LEVEL="vvvvv" ;;
esac

これにより、オペレーターはすべてのクライアントに対して LOG_LEVEL=debug と統一した形で設定できます。

Historical Proofs のマルチステージ起動

最も注目すべきコードは 75行目 から始まります。RETH_HISTORICAL_PROOFS=true のとき、Reth は単純に起動してリクエストを処理するわけにはいきません。historical proofs データベースを初期化する必要がありますが、そのためにはまずノードが genesis ブロックを越えて同期する必要があります。問題は、Reth が古いデータベースを読み取り専用モードで起動することをサポートしていない点です。

その解決策が3フェーズの起動プロセスです。

sequenceDiagram
    participant EP as reth-entrypoint
    participant R1 as Reth (phase 1)
    participant RPC as JSON-RPC localhost
    participant R2 as Reth proofs init
    participant R3 as Reth (final)

    EP->>R1: Start reth node in background
    Note over R1: Syncing from genesis...
    loop Up to 6 hours
        EP->>RPC: eth_getBlockByNumber("latest")
        RPC-->>EP: block number = 0x0
        Note over EP: Still at genesis, wait...
    end
    EP->>RPC: eth_getBlockByNumber("latest")
    RPC-->>EP: block number > 0x0
    Note over EP: Synced past genesis!
    EP->>R1: kill (SIGTERM)
    EP->>EP: wait_for_pid (poll /proc/PID)
    Note over R1: Graceful shutdown
    EP->>R2: reth proofs init
    Note over R2: Initialize historical proofs DB
    EP->>R3: exec reth node (with --proofs-history)
    Note over R3: Normal operation

フェーズ1:genesis 以降への同期。 Reth は & でバックグラウンドプロセスとして起動し、localhost の HTTP ポートにのみバインドします。スクリプトはポーリングループに入り、eth_getBlockByNumber への JSON-RPC コールを繰り返してブロックが 0x0 を超えているかを確認します。タイムアウトは6時間(89行目60 * 60 * 6 秒)に設定されています。大規模なデータベースでは Reth の static file manager の初期化が非常に遅くなる可能性があるためです。

フェーズ2:グレースフルシャットダウンと proofs 初期化。 genesis を超えて同期したら、スクリプトはバックグラウンドの Reth プロセスに SIGTERM を送り、wait_for_pid ヘルパー(53〜67行目)を使って終了を待ちます。

wait_for_pid() {
    local pid="$1"
    if [[ ! -e "/proc/$pid" ]]; then
        echo "Process $pid does not exist." >&2
        return 1
    fi
    while [[ -e "/proc/$pid" ]]; do
        sleep 1
    done
}

wait を使わずに /proc/$pid をポーリングしているのは、wait が同じシェルセッションの子プロセスにしか機能しないためで、このプロセスはバックグラウンドで起動されています。Reth の終了後、スクリプトは reth proofs init を実行して historical proofs データベースを構築します。

フェーズ3:通常起動。 最後に、Reth は --proofs-history--proofs-history.storage-path フラグを付けて exec で再起動し、スクリプトプロセスを置き換えます。

補足: 6時間のタイムアウトは極端に見えるかもしれませんが、実際の運用環境に合わせて設定された値です。HDD や限られたメモリ環境では、約2TBの Base mainnet データベースに対する static file manager の初回スキャンが本当に数時間かかることがあります。NVMe かつ 64GB 以上の RAM があれば、数分で完了するでしょう。

完全な起動タイムライン

すべてをまとめると、docker compose up から同期開始までの全シーケンスは次のようになります。

flowchart TD
    A["docker compose up"] --> B["Start execution container"]
    B --> C["Run execution-entrypoint<br/>(reth/geth/nethermind)"]
    C --> D["Write JWT secret"]
    D --> E["Start execution client binary"]
    A --> F["Start node container<br/>(after depends_on)"]
    F --> G["Run consensus-entrypoint"]
    G --> H{"USE_BASE_CONSENSUS?"}
    H -->|true| I["base-consensus-entrypoint"]
    H -->|false| J["op-node-entrypoint"]
    I --> K["Poll Engine API for 401"]
    J --> K
    K --> L["Detect public IP"]
    L --> M["Write JWT secret"]
    M --> N["exec consensus binary"]
    N --> O["Connect to Engine API"]
    O --> P["Node syncing ✓"]

実行クライアントが先に起動し、独立して同期を開始します。コンセンサスクライアントは Engine API の準備完了(HTTP 401)が確認されるまで待機し、パブリック IP を取得して、共有 JWT secret を書き込んでから起動します。接続後は、コンセンサスクライアントが L1 からのブロックデリベーションを主導し、実行クライアントがステート実行を担います。

次回予告

起動スクリプトが共通のパターンを持ちながらも複雑さが異なることを見てきました。しかし、各実行クライアントがなぜ異なるのかについては、まだ表面をなぞったにすぎません。第3回では3つのクライアントを正面から比較します — Dockerfile、ビルドパイプライン、ランタイム設定、そして base-consensus が Reth イメージにしか同梱されないという重要な非対称性について掘り下げます。