Read OSS

Base Node のアーキテクチャ:30 ファイルが Ethereum L2 を動かす仕組み

中級

前提知識

  • Docker および Docker Compose の基礎知識
  • Ethereum L1/L2 アーキテクチャの基本理解(実行レイヤーとコンセンサスレイヤーの違い)
  • OP Stack(Optimism のロールアップフレームワーク)の概要

Base Node のアーキテクチャ:30 ファイルが Ethereum L2 を動かす仕組み

多くのリポジトリをクローンすると、src/ ディレクトリやパッケージマネージャー、そして単一のバイナリを生成するビルドシステムを備えたアプリケーションが待っています。しかし base/node は違います。これは デプロイオーケストレーター です。およそ 30 のファイルが、4 つの異なるリポジトリから 3 つのプログラミング言語にまたがる上流バイナリを取得・ビルド・接続し、動作する Base L2 Ethereum ノードを作り上げます。このメンタルモデルを掴むことが、リポジトリ全体を読み解く鍵になります。

本記事は全 5 回のシリーズの第 1 回です。コードベース全体をマッピングしながら、すべての土台となる 2 サービス構成を説明し、1 つの JSON ファイルがビルドパイプライン全体を駆動する仕組みを解説します。

このリポジトリの正体

ブロックチェーンのコンセンサスロジックや EVM の実行コードを期待してクローンすると、戸惑うかもしれません。クリティカルパスに main() は存在しないのです。base/node は、次のことを行うオピニオネーテッドなコンポジションレイヤーです。

  1. バージョンを厳密にピン留めする — Reth、Geth、Nethermind、op-node という 4 つの上流依存関係の正確なバージョンを 1 つの JSON ファイルで管理する
  2. ソースからビルドする — Docker のマルチステージビルド内でコミットハッシュを検証しながらビルドし、サプライチェーン攻撃を防ぐ
  3. 各サービスを接続する — サービスディスカバリー、JWT 認証、フィーチャーフラグの切り替えを担うエントリーポイントスクリプトで繋ぎ合わせる
  4. 起動を制御する — Docker Compose でオーケストレーションし、コンセンサスクライアントが接続する前に実行クライアントが準備完了になることを保証する
flowchart LR
    subgraph "base/node repository"
        DC[docker-compose.yml]
        VJ[versions.json]
        EP[Entrypoint Scripts]
        DF[Dockerfiles]
    end
    subgraph "Upstream Repos"
        R[base/base<br/>Reth + base-consensus]
        G[ethereum-optimism/op-geth]
        N[NethermindEth/nethermind]
        O[ethereum-optimism/optimism<br/>op-node]
    end
    VJ -->|pins versions| DF
    DF -->|clones & builds| R
    DF -->|clones & builds| G
    DF -->|clones & builds| N
    DF -->|clones & builds| O
    DC -->|orchestrates| EP

最終的には、docker compose up の 1 コマンドで、選択した実行クライアントに関わらず同期中の Base ノードが起動します。

ディレクトリ構造とファイルの役割

ファイルは ~30 しかないため、迷うような深いディレクトリ階層はありません。ただし、各ファイルはビルド・実行パイプラインの異なるステージに対応した明確な役割を持っています。

パス 役割 説明
docker-compose.yml オーケストレーション 2 つのサービスとその接続関係を定義する
.env ユーザー設定 デフォルトのクライアント選択とデータディレクトリ
.env.mainnet / .env.sepolia ネットワーク設定 ネットワーク固有のエンドポイント、ブートノード、キャッシュ設定
versions.json バージョン管理 すべての上流依存関係のバージョンを一元管理する唯一の情報源
versions.env 生成済み設定 Dockerfile が参照するシェルエクスポート形式のファイル
geth/Dockerfile ビルドパイプライン op-geth + op-node のマルチステージ Docker ビルド
reth/Dockerfile ビルドパイプライン base-reth-node + base-consensus + op-node のマルチステージ Docker ビルド
nethermind/Dockerfile ビルドパイプライン Nethermind + op-node のマルチステージ Docker ビルド
geth/geth-entrypoint ランタイム キャッシュチューニングを含む Geth 起動スクリプト
reth/reth-entrypoint ランタイム Flashblocks および historical proofs 対応の Reth 起動スクリプト
nethermind/nethermind-entrypoint ランタイム Nethermind 起動スクリプト
consensus-entrypoint ランタイム op-node または base-consensus へルーティングするディスパッチャー
op-node-entrypoint ランタイム レガシーコンセンサスクライアントの起動スクリプト
base-consensus-entrypoint ランタイム follow モード対応の新しいコンセンサスクライアント起動スクリプト
supervisord.conf プロセス管理 スタンドアロン Docker 実行時のフォールバック
dependency_updater/ ツール バージョン追跡を自動化する Go CLI
.github/workflows/ CI/CD ビルド・テスト・リリースの自動化

ヒント: このフラットな構造は意図的な設計です。各実行クライアントは専用のディレクトリを持ち、その中身は Dockerfile とエントリーポイントスクリプトの 2 ファイルだけです。それ以外はすべてルートに置かれます。新しいクライアントを追加するには、ディレクトリを作成し、2 ファイルを追加して、versions.json を更新するだけで済みます。

2 サービス構成

base/node の核心は docker-compose.yml です。コンパクトながら、ランタイムのトポロジー全体を定義しています。

flowchart TB
    subgraph Docker["Docker Compose Network"]
        subgraph EX["execution service"]
            EC[Execution Client<br/>Reth / Geth / Nethermind]
        end
        subgraph ND["node service"]
            CC[Consensus Client<br/>op-node / base-consensus]
        end
    end
    CC -->|"Engine API<br/>port 8551<br/>JWT auth"| EC
    EC -->|"depends_on"| ND
    Internet -->|"P2P :30303"| EC
    Internet -->|"P2P :9222"| CC
    User -->|"RPC :8545"| EC

両サービスは 同一の Dockerfile を使い、ビルド時に CLIENT 環境変数で切り替えます。

# docker-compose.yml line 5
dockerfile: ${CLIENT:-geth}/Dockerfile

これにより docker compose build は 2 つの同一イメージを生成します。サービスの差別化は、ランタイムの command オーバーライドによって行われます。execution サービスは bash ./execution-entrypoint を、node サービスは bash ./consensus-entrypoint を実行します。

26 行目depends_on 指定により、Docker はまず実行サービスを起動します。ただし Docker の depends_on が待つのは コンテナの起動 だけで、内部のアプリケーションの準備完了は保証しません。実際のレディネス調整は、コンセンサスエントリーポイントスクリプトが Engine API をポーリングすることで行われます。このパターンは Part 2 で詳しく解説します。

supervisord.conf が存在するのに使われない理由

各 Dockerfile の末尾には CMD ["/usr/bin/supervisord"] があり、supervisord.conf が両プロセスを 1 つのコンテナ内で管理するよう定義されています。これは Docker Compose を使わずにイメージを単体実行する場合のフォールバックで、supervisord が 2 つのプロセスを管理する単一コンテナとして動作します。

docker compose up を使うと、docker-compose.ymlcommand 指定が各 Dockerfile の CMD を上書きします。実行コンテナは実行エントリーポイントだけを、ノードコンテナはコンセンサスエントリーポイントだけを実行します。supervisord が呼び出されることはありません。

flowchart TD
    A{"How are you<br/>running it?"} -->|"docker compose up"| B["command overrides CMD<br/>Each service runs one process"]
    A -->|"docker run"| C["CMD runs supervisord<br/>Both processes in one container"]
    B --> D["execution-entrypoint"]
    B --> E["consensus-entrypoint"]
    C --> F["supervisord manages both<br/>execution + consensus"]

このデュアルモード設計は実用的な判断です。Docker Compose を使えば、ログの分離・独立した再起動・リソースの分離といった恩恵が得られます。一方、Compose が使えない環境でも、supervisord を使った単一コンテナで問題なく動作します。

3 層構成の設定システム

設定は 3 つの層を経由して流れ、後の層が前の層を上書きします。「なぜノードが意図と異なるエンドポイントに接続しているのか」をデバッグするとき、この層構造を理解しているかどうかが解決の速さを左右します。

flowchart TD
    A[".env<br/>CLIENT=geth<br/>USE_BASE_CONSENSUS=true<br/>HOST_DATA_DIR=./geth-data"] -->|"lowest priority"| D["Effective Config"]
    B[".env.mainnet / .env.sepolia<br/>Network endpoints, bootnodes,<br/>cache settings, L1 config"] -->|"medium priority<br/>(loaded via env_file)"| D
    C["Shell environment<br/>export CLIENT=reth"] -->|"highest priority"| D

第 1 層:.envデフォルト設定 はわずか 3 行です。

CLIENT=${CLIENT:-geth}
HOST_DATA_DIR=./${CLIENT}-data
USE_BASE_CONSENSUS=true

デフォルトの実行クライアントを Geth に設定し、base-consensus をデフォルトのコンセンサスクライアントとして有効化しています。

第 2 層:.env.mainnet / .env.sepoliadocker-compose.yml の 19 行目 にある env_file 指定で読み込まれます。NETWORK_ENV 変数のデフォルトは .env.mainnet です。Sepolia テストネットに切り替えるには NETWORK_ENV=.env.sepolia を設定してください。

第 3 層:シェル環境変数docker compose up 実行前にシェルで設定した変数は、両ファイルより優先されます。

デュアルネームスペースパターン

ネットワーク設定ファイルで戸惑いやすいのが、変数の二重定義です。.env.mainnet を見てみましょう。

OP_NODE_L2_ENGINE_RPC=http://execution:8551       # for op-node
BASE_NODE_L2_ENGINE_RPC=ws://execution:8551        # for base-consensus

同じ Engine API エンドポイントが、異なるプレフィックスで 2 回定義されています。これは、op-nodeOP_NODE_* 変数を読み込む一方、base-consensusBASE_NODE_* 変数を読み込むためです。同じ情報が必要でも、それぞれ異なる環境変数の命名規則に従っています。OP_NODE_* は Optimism 由来のレガシーな規則であり、BASE_NODE_* は Base 固有の新しい規則です。

ヒント: ネットワーク設定ファイルにハードコードされた JWT シークレット(688f5d737bad920bdfb2fc2f488d6b6209eebda1dae949a8de91398d932c517a)はセキュリティ上の懸念に見えるかもしれませんが、これは意図的な設計です。Engine API は Docker ネットワーク 内部 にのみ公開されており、ホストからは到達できません。この JWT は外部の攻撃者から守るためではなく、Engine API の仕様を満たすために存在しています。

バージョン管理:versions.json から Docker ビルドへ

versions.json は、すべての上流依存関係を一元管理する唯一の情報源です。

{
  "base_reth_node": {
    "tag": "v0.7.0",
    "commit": "2b0d89d4267ae7b2893e1719d2ba026074e4a8b8",
    "owner": "base",
    "repo": "base",
    "tracking": "release"
  },
  "op_geth": {
    "tag": "v1.101702.0",
    "commit": "d0734fd5f44234cde3b0a7c4beb1256fc6feedef",
    "owner": "ethereum-optimism",
    "repo": "op-geth",
    "tracking": "release"
  }
}

各エントリーには、クローンする Git タグ(tag)、そのタグが指すべきコミット SHA(commit)、そして自動アップデーターが新バージョンを探す方法を制御する tracking モードが含まれています。アップデーターの詳細は Part 4 で解説します。

ここで重要なのは、Docker ビルド中に行われる 2 段階の検証 です。Geth Dockerfile の 9〜11 行目 のクローンステップを見てみましょう。

RUN . /tmp/versions.env && git clone $OP_GETH_REPO --branch $OP_GETH_TAG --single-branch . && \
    git switch -c branch-$OP_GETH_TAG && \
    bash -c '[ "$(git rev-parse HEAD)" = "$OP_GETH_COMMIT" ]'

まず指定されたタグでクローンし、次に HEAD が期待するコミットハッシュと一致するかを検証します。上流のメンテナーがタグを別のコミットに移動させる「タグ移動攻撃」が行われた場合、ビルドは即座に失敗します。サプライチェーンセキュリティを Docker レイヤーで実装しているわけです。

flowchart LR
    VJ[versions.json] -->|"dependency_updater"| VE[versions.env]
    VE -->|"COPY into build"| DF[Dockerfile]
    DF -->|"1. git clone at tag"| REPO[Upstream Repo]
    DF -->|"2. verify commit hash"| CHECK{SHA matches?}
    CHECK -->|Yes| BUILD[Build binary]
    CHECK -->|No| FAIL[Build fails]

versions.env は生成済みのアーティファクトで、Dockerfile がビルド中に source できるシェルエクスポート形式のファイルです。各依存関係は、一貫した命名規則に従った 3 つの変数として定義されます。

export OP_GETH_TAG=v1.101702.0
export OP_GETH_COMMIT=d0734fd5f44234cde3b0a7c4beb1256fc6feedef
export OP_GETH_REPO=https://github.com/ethereum-optimism/op-geth.git

依存関係のキーを大文字化した値(op_gethOP_GETH)に、_TAG_COMMIT_REPO のサフィックスを付けた構成です。このファイルはリポジトリにコミットされており、versions.json が変更されるたびに自動で再生成されます。

すべての要素の繋がり

個々のコンポーネントをマッピングし終えたところで、それらがエンドツーエンドでどう繋がるかを確認しましょう。

flowchart TD
    USER["Operator runs:<br/>CLIENT=reth docker compose up"] --> DC[docker-compose.yml]
    DC -->|"builds using"| RDF[reth/Dockerfile]
    RDF -->|"reads versions from"| VE[versions.env]
    VE -->|"generated from"| VJ[versions.json]
    RDF -->|"clones & builds"| RETH[base-reth-node binary]
    RDF -->|"clones & builds"| BC[base-consensus binary]
    RDF -->|"clones & builds"| OPN[op-node binary]
    DC -->|"starts execution service"| EX["reth-entrypoint<br/>→ base-reth-node"]
    DC -->|"starts node service"| ND["consensus-entrypoint<br/>→ base-consensus"]
    ND -->|"Engine API + JWT"| EX

フローをまとめると次のようになります。ユーザーがクライアントを選択し、Docker Compose が対応する Dockerfile をトリガーします。Dockerfile は versions.env を参照してクローン先のリポジトリとコミットを特定し、バイナリをソースからビルドします。最後にエントリーポイントスクリプトがランタイム設定とサービス間の連携を処理します。

次回予告

アーキテクチャの骨格が見えてきました。2 つのサービス、3 層の設定、バージョン管理されたビルドパイプラインです。しかし、コンテナが実際に起動したとき何が起こるのかは、まだ説明していません。起動シーケンスには、ポーリングループ、パブリック IP の検出、JWT シークレットの書き込み、そして Reth の場合は最大 6 時間かかることもある多段階の初期化処理が含まれます。

Part 2 では、docker compose up から完全に同期したノードが動き出すまでの全ステップを追いながら、コンセンサスディスパッチャーパターンと Reth の historical proofs 初期化サブシーケンスを詳しく解説します。