Read OSS

From main() to Running Node: The Geth Boot Process

Intermediate

Prerequisites

  • Article 1: Architecture and Directory Map
  • Go language basics (goroutines, channels, interfaces)

From main() to Running Node: The Geth Boot Process

Every complex system has a boot sequence — the choreography that takes an inert binary and transforms it into a running service. Geth's boot process is surprisingly readable once you know the key players. It follows a clear pipeline: parse CLI flags, create a protocol-agnostic Node container, wire up the Ethereum service inside it, open network endpoints, and start all registered lifecycles.

In this article we'll follow every step of that pipeline, from the five-line main() function to the moment the node begins processing blocks. Along the way, you'll discover the Node-as-Container pattern that decouples protocol logic from infrastructure concerns — arguably the most important architectural pattern in the entire codebase.

CLI Framework and Entry Point

Geth uses the urfave/cli/v2 framework for command-line parsing. The setup happens in init(), which runs before main():

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

The init() function does three things: sets app.Action = geth (the default command), registers all subcommands (like init, import, console, attach), and registers an enormous list of flags spanning node configuration, RPC settings, metrics, and debugging.

A nice detail: flags.AutoEnvVars(app.Flags, "GETH") automatically generates environment variable names for every flag. The --datadir flag becomes GETH_DATADIR, for instance. No boilerplate needed.

The actual main() is five lines:

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

When no subcommand is specified, urfave/cli dispatches to app.Action, which is the geth function.

The Boot Path: geth() → makeFullNode() → startNode()

The geth() function is the heart of the boot sequence — thirteen lines that create and run a full Ethereum node:

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
}

Four steps: prepare() sets up logging and memory caches. makeFullNode() creates the Node container and wires in the Ethereum service. startNode() boots everything up. stack.Wait() blocks until the node is shut down (via signal or API call).

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: If you're debugging a startup issue, the prepare()makeFullNode()startNode() sequence is always where to look. These three function calls are the complete boot story.

The Node-as-Container Pattern

As we covered in Part 1, the node.Node struct is a protocol-agnostic service container. It knows nothing about Ethereum specifically — it manages P2P networking, RPC servers, databases, and service lifecycles:

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{}
}

The Node.New() constructor sets up the P2P server configuration, creates RPC endpoint servers (HTTP, WebSocket, IPC, and authenticated variants), initializes the account manager, and acquires a file lock on the data directory to prevent concurrent access.

Notice the authenticated HTTP and WebSocket servers (httpAuth, wsAuth) — these are specifically for the Engine API. The line node.httpAuth.disableHTTP2 = true is a pragmatic detail: the Engine API doesn't need HTTP/2, and disabling it avoids potential complications with consensus layer clients.

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

Constructing the Ethereum Service: eth.New()

The eth.New() constructor is the largest single function in the codebase — nearly 250 lines that wire together the entire Ethereum protocol. Here's the sequence:

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"]

The function opens the chain database via stack.OpenDatabaseWithOptions("chaindata", ...), loads or initializes the chain configuration, creates a consensus engine, builds the entire blockchain object, initializes transaction pools (both legacy and blob), creates the P2P handler, sets up the miner, and wires up the API backend.

The critical registration step happens at the end of eth.New():

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

These three calls connect the Ethereum service to the Node container. The Node doesn't know or care that it's running Ethereum — it just manages the lifecycle and exposes the APIs.

Configuration Layering

Configuration in Geth flows through multiple layers, each adding specificity:

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"]

The ethconfig.Defaults provide sensible starting values — snap sync mode, 2GB database cache, 60-minute trie timeout, 50M gas cap for RPC calls. CLI flags override these defaults. The resulting Config struct then drives construction of all subsystems.

The params.ChainConfig is particularly interesting because it uses a dual activation scheme: earlier forks (Homestead through GrayGlacier) activate by block number, while post-Merge forks (Shanghai onward) activate by timestamp. This is visible in the struct's fields — HomesteadBlock *big.Int versus ShanghaiTime *uint64.

Pre-configured networks (Mainnet, Sepolia, Holesky, Hoodi) have their ChainConfig defined as package-level variables in params/config.go, so launching on a known network requires no manual configuration.

The Three-Phase Lifecycle and Registration

The Node has a simple state machine with three states, defined in node/node.go:

const (
    initializingState = iota
    runningState
    closedState
)

Services can only register during initializingState. The RegisterLifecycle method enforces this:

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)
}

When Node.Start() is called, it transitions to runningState, opens all network endpoints (P2P, RPC), and then iterates through registered lifecycles calling Start() on each. If any lifecycle fails to start, all previously started ones are stopped in reverse order — a clean rollback pattern.

Shutdown is equally orderly. Node.Close() stops lifecycles in reverse registration order, then tears down RPC endpoints, P2P networking, and finally closes all tracked databases. The closeTrackingDB wrapper ensures databases opened through the Node are automatically closed during shutdown, preventing resource leaks.

Tip: The stack.Wait() call at the end of geth() blocks on a channel that's closed in Node.doClose(). To trigger graceful shutdown, send an interrupt signal (SIGINT/SIGTERM) — the signal handler calls stack.Close(), which unblocks Wait() and lets geth() return cleanly.

With the node running, the next article dives into what happens when blocks arrive: how they flow through the BlockChain manager, get validated by the consensus Engine, and are executed transaction-by-transaction through the EVM.