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 を設定し、init・import・console・attach などのサブコマンドを登録し、ノード設定・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/cli は app.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 サーバー(httpAuth、wsAuth)は 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)の ChainConfig は params/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 でトランザクションがひとつひとつ実行される過程を見ていきましょう。