Read OSS

main() からノード起動まで: Geth のブートプロセス

中級

前提知識

  • 第1回: アーキテクチャとディレクトリ構成
  • Go 言語の基礎 (goroutine、チャネル、インターフェース)

main() からノード起動まで: Geth のブートプロセス

どんな複雑なシステムにも、バイナリをサービスとして起動させるための「ブートシーケンス」があります。Geth のブートプロセスは、主要な登場人物さえ把握してしまえば、驚くほど読みやすい構造になっています。処理の流れは明快です。CLI フラグのパース、プロトコルに依存しない Node コンテナの生成、Ethereum サービスの組み込み、ネットワークエンドポイントのオープン、登録済みライフサイクルの起動 — この順番で進みます。

本記事では、5行の main() 関数からノードがブロックを処理し始める瞬間まで、このパイプラインを一歩ずつ追いかけていきます。その過程で、プロトコルロジックをインフラの関心事から切り離す Node-as-Container パターンにも出会うことになります。これはコードベース全体を通して、最も重要なアーキテクチャパターンといっても過言ではありません。

CLI フレームワークとエントリーポイント

Geth のコマンドライン解析には urfave/cli/v2 フレームワークを使用しています。セットアップは main() の前に実行される init() 関数で行われます。

sequenceDiagram
    participant OS as Operating System
    participant Init as init()
    participant Main as main()
    participant App as urfave/cli App

    OS->>Init: Process starts
    Init->>App: app.Action = geth
    Init->>App: Register commands (init, import, console, ...)
    Init->>App: Register flags (nodeFlags, rpcFlags, metricsFlags, ...)
    Init->>App: AutoEnvVars(flags, "GETH")
    OS->>Main: main()
    Main->>App: app.Run(os.Args)
    App->>App: Parse flags, dispatch to action

init() 関数が行うことは3つです。デフォルトコマンドとして app.Action = geth を設定し、initimportconsoleattach などのサブコマンドを登録し、ノード設定・RPC・メトリクス・デバッグに関わる膨大なフラグ群を登録します。

便利な仕組みとして、flags.AutoEnvVars(app.Flags, "GETH") はすべてのフラグに対応する環境変数名を自動生成します。たとえば --datadir フラグは GETH_DATADIR に対応します。ボイラープレートは一切不要です。

実際の main() はわずか5行です。

func main() {
    if err := app.Run(os.Args); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

サブコマンドが指定されなかった場合、urfave/cliapp.Action に設定された geth 関数にディスパッチします。

ブートの流れ: geth() → makeFullNode() → startNode()

geth() 関数はブートシーケンスの核心です。わずか13行で Ethereum フルノードを生成・起動します。

func geth(ctx *cli.Context) error {
    if args := ctx.Args().Slice(); len(args) > 0 {
        return fmt.Errorf("invalid command: %q", args[0])
    }
    prepare(ctx)
    stack := makeFullNode(ctx)
    defer stack.Close()
    startNode(ctx, stack, false)
    stack.Wait()
    return nil
}

処理は4ステップです。prepare() でロギングとメモリキャッシュを準備し、makeFullNode() で Node コンテナを作成して Ethereum サービスを組み込み、startNode() ですべてを起動します。stack.Wait() は、シグナルまたは API 呼び出しによってノードがシャットダウンされるまで処理をブロックします。

sequenceDiagram
    participant G as geth()
    participant P as prepare()
    participant MFN as makeFullNode()
    participant SN as startNode()
    participant N as Node

    G->>P: Setup logging, metrics
    G->>MFN: Create Node + Ethereum service
    MFN-->>G: return stack (*node.Node)
    G->>SN: Start all services
    SN->>N: utils.StartNode() → Node.Start()
    G->>N: stack.Wait() — blocks until shutdown

Tip: 起動時の問題をデバッグするなら、prepare()makeFullNode()startNode() の流れを必ず確認しましょう。この3つの関数呼び出しが、ブートの全体像を表しています。

Node-as-Container パターン

第1回で触れたように、node.Node 構造体はプロトコルに依存しないサービスコンテナです。Ethereum に関する知識は一切持たず、P2P ネットワーク、RPC サーバー、データベース、サービスのライフサイクルを管理するだけです。

type Node struct {
    eventmux      *event.TypeMux
    config        *Config
    accman        *accounts.Manager
    log           log.Logger
    stop          chan struct{}
    server        *p2p.Server
    state         int           // initializingState → runningState → closedState
    lifecycles    []Lifecycle
    rpcAPIs       []rpc.API
    http          *httpServer
    ws            *httpServer
    httpAuth      *httpServer   // JWT-authenticated for Engine API
    wsAuth        *httpServer
    ipc           *ipcServer
    inprocHandler *rpc.Server
    databases     map[*closeTrackingDB]struct{}
}

Node.New() コンストラクタでは、P2P サーバーの設定、RPC エンドポイントサーバー(HTTP、WebSocket、IPC、および認証付きバリアント)の作成、アカウントマネージャーの初期化、そして同時アクセスを防ぐためのデータディレクトリへのファイルロック取得が行われます。

認証付きの HTTP・WebSocket サーバー(httpAuthwsAuth)は Engine API 専用のものです。node.httpAuth.disableHTTP2 = true という1行は実用的な判断の表れです。Engine API は HTTP/2 を必要とせず、無効化することでコンセンサスレイヤークライアントとの潜在的な問題を回避できます。

classDiagram
    class Node {
        -config *Config
        -server *p2p.Server
        -lifecycles []Lifecycle
        -rpcAPIs []rpc.API
        -http *httpServer
        -httpAuth *httpServer
        -ipc *ipcServer
        -databases map
        +Start() error
        +Close() error
        +Wait()
        +RegisterLifecycle(Lifecycle)
        +RegisterAPIs([]rpc.API)
        +RegisterProtocols([]p2p.Protocol)
    }
    class Lifecycle {
        <<interface>>
        +Start() error
        +Stop() error
    }
    class Ethereum {
        +Start() error
        +Stop() error
    }
    Node o-- Lifecycle
    Ethereum ..|> Lifecycle

Ethereum サービスの構築: eth.New()

eth.New() コンストラクタはコードベース中で最も大きな単一関数です。約250行にわたって Ethereum プロトコル全体を組み立てます。処理の流れを以下に示します。

flowchart TD
    A["Open chaindata database"] --> B["Load chain config + genesis"]
    B --> C["Create consensus engine<br/>(beacon wrapping clique or ethash)"]
    C --> D["Create BlockChain"]
    D --> E["Initialize FilterMaps log index"]
    E --> F["Create legacy tx pool + blob pool"]
    F --> G["Create TxPool aggregator"]
    G --> H["Create handler<br/>(P2P coordinator)"]
    H --> I["Create Miner<br/>(payload builder)"]
    I --> J["Create EthAPIBackend + gas oracle"]
    J --> K["Register APIs, Protocols,<br/>Lifecycle with Node"]

stack.OpenDatabaseWithOptions("chaindata", ...) でチェーンデータベースを開き、チェーン設定のロードまたは初期化を行います。続いてコンセンサスエンジンの作成、ブロックチェーンオブジェクトの構築、トランザクションプール(legacy と blob の両方)の初期化、P2P ハンドラーの作成、マイナーのセットアップ、API バックエンドの接続と進みます。

重要な登録処理は eth.New() の末尾で行われます。

stack.RegisterAPIs(eth.APIs())
stack.RegisterProtocols(eth.Protocols())
stack.RegisterLifecycle(eth)

この3つの呼び出しによって、Ethereum サービスが Node コンテナに接続されます。Node は Ethereum を動かしていることを知りませんし、知る必要もありません。ライフサイクルの管理と API の公開だけを担います。

設定のレイヤー構造

Geth の設定は複数のレイヤーを通じて流れ込み、それぞれが詳細を上書きしていきます。

flowchart TD
    CLI["CLI Flags<br/>(--syncmode, --cache, ...)"] --> ETH_CFG["ethconfig.Config<br/>Protocol-level defaults"]
    CLI --> NODE_CFG["node.Config<br/>Infrastructure settings"]
    ETH_CFG --> CHAIN_CFG["params.ChainConfig<br/>Fork schedule"]
    ETH_CFG --> BC_CFG["core.BlockChainConfig<br/>Runtime parameters"]
    CHAIN_CFG --> RULES["params.Rules<br/>Per-block fork flags"]

ethconfig.Defaults には合理的なデフォルト値が定義されています。snap sync モード、2GB のデータベースキャッシュ、60分のトライタイムアウト、RPC 呼び出しの 50M gas cap などです。CLI フラグはこれらのデフォルト値を上書きします。最終的に得られる Config 構造体が、全サブシステムの構築を駆動します。

params.ChainConfig が興味深いのは、二重のアクティベーション方式を採用している点です。初期のフォーク(Homestead から GrayGlacier まで)はブロック番号で、Merge 以降のフォーク(Shanghai 以降)はタイムスタンプでアクティベートされます。構造体のフィールドにもこの違いが表れています。HomesteadBlock *big.Int に対して ShanghaiTime *uint64 という具合です。

既知のネットワーク(Mainnet、Sepolia、Holesky、Hoodi)の ChainConfigparams/config.go にパッケージレベルの変数として定義されているため、これらのネットワークで起動する場合は手動設定が不要です。

3フェーズのライフサイクルと登録

Node は3つの状態を持つシンプルなステートマシンを実装しています。node/node.go に定義されています。

const (
    initializingState = iota
    runningState
    closedState
)

サービスが登録できるのは initializingState の間だけです。RegisterLifecycle メソッドがこれを強制します。

func (n *Node) RegisterLifecycle(lifecycle Lifecycle) {
    n.lock.Lock()
    defer n.lock.Unlock()
    if n.state != initializingState {
        panic("can't register lifecycle on running/stopped node")
    }
    // ...
    n.lifecycles = append(n.lifecycles, lifecycle)
}

Node.Start() が呼ばれると、状態が runningState に遷移し、すべてのネットワークエンドポイント(P2P、RPC)が開かれ、登録済みのライフサイクルに対して順番に Start() が呼び出されます。いずれかのライフサイクルの起動に失敗した場合は、それまでに起動したものが逆順で停止されます。クリーンなロールバックパターンです。

シャットダウンも同様に整然としています。Node.Close() はライフサイクルを登録の逆順で停止し、次に RPC エンドポイントと P2P ネットワークを解体し、最後にすべての追跡済みデータベースを閉じます。closeTrackingDB ラッパーにより、Node を通じて開かれたデータベースはシャットダウン時に自動的に閉じられ、リソースリークを防ぎます。

Tip: geth() 末尾の stack.Wait() は、Node.doClose() 内で閉じられるチャネルでブロックします。グレースフルシャットダウンを発動するには、割り込みシグナル(SIGINT/SIGTERM)を送信しましょう。シグナルハンドラーが stack.Close() を呼び出し、Wait() のブロックが解除されて geth() がクリーンに返ります。

ノードが無事起動したところで、次回はブロックが到着したときに何が起きるかを掘り下げます。BlockChain マネージャーをどう流れ、コンセンサスエンジンでどう検証され、EVM でトランザクションがひとつひとつ実行される過程を見ていきましょう。