Read OSS

启动序列深度解析:从 docker compose up 到节点开始同步

中级

前置知识

  • 第 1 篇:架构与代码库导览
  • 了解 Engine API 的概念(共识层与执行层之间的通信协议)
  • 具备 JWT 认证的基本知识

启动序列深度解析:从 docker compose up 到节点开始同步

第 1 篇中,我们梳理了代码库的整体结构,了解到 base/node 通过 Engine API 将两个服务连接在一起。但架构图是静态的——当你输入 docker compose up 时,究竟发生了什么?答案是一套精心设计的多脚本协作流程,其中的启动顺序并非由某个框架来保障,而是通过 curl 轮询循环和进程生命周期管理来实现的。

本文将追踪完整的启动序列:从 Docker Compose 启动服务,经过共识层入口分发器,深入各客户端的初始化逻辑——直到 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 端口。真正的就绪同步完全由共识层的入口脚本来负责。

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

尽管两个容器使用的是同一镜像,docker-compose.yml 中的 command 覆盖配置将它们区分开来。执行服务运行 bash ./execution-entrypoint第 13 行),节点服务运行 bash ./consensus-entrypoint第 33 行)。由于每个 Dockerfile 都会将所有入口脚本 COPY/app 目录,这两个文件在镜像中均存在。

共识层入口分发器

consensus-entrypoint 是一个简洁但关键的分发器,它根据单个环境变量将请求路由到两个共识客户端之一:

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 篇将会详细介绍:只有 Reth 的 Dockerfile 会打包 base-consensus 二进制文件及其入口脚本。如果你设置了 CLIENT=gethUSE_BASE_CONSENSUS=true,Geth 镜像中并不存在 base-consensus-entrypoint,分发器会给出明确的错误提示并优雅退出,而不是抛出难以定位的"文件未找到"错误。

注意这里使用了 exec——shell 进程会被目标脚本替换。这意味着共识入口的 PID 会成为 PID 1(或继承父进程 PID),这对信号处理和 Docker 的停止行为至关重要。

共识客户端启动:等待 Engine API 就绪

两个共识入口脚本都遵循相同的三阶段模式:校验环境变量 → 等待 Engine API → exec 二进制文件。下面对它们进行逐一比较。

阶段一:环境变量校验

base-consensus-entrypoint 检查 BASE_NODE_NETWORK 是否已设置:

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

op-node-entrypoint 则检查 OP_NODE_NETWORKOP_NODE_ROLLUP_CONFIG 是否存在——既支持命名网络,也支持自定义 rollup 配置文件。这体现了 op-node 更灵活的配置模型。

阶段二: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 格式存储(ws://execution:8551),但 curl 需要 HTTP 协议。这个 bash 参数展开语法直接在变量展开时将 ws 替换为 http

阶段三:IP 发现、JWT 写入与 exec

确认 Engine API 就绪后,两个脚本都会通过共享的 get_public_ip() 函数发现节点的公网 IP,用于 P2P 广播。该函数会依次尝试四个服务商(ifconfig.me、api.ipify.org、ipecho.net、v4.ident.me)。之后写入 JWT secret,并 exec 启动二进制文件。

base-consensus-entrypoint 有一个独特功能——跟随模式(follow mode)

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

跟随模式允许共识客户端从另一个 L2 节点的 RPC 端点同步,而无需从 L1 推导。这对于快速引导新节点非常有用。

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

执行客户端的启动模式

执行客户端的入口脚本在共识客户端连接之前就已经开始运行,负责写入 JWT secret、创建数据目录,并以正确的参数启动二进制文件。三个客户端在这方面各有不同。

Geth:精细的参数化配置

geth/geth-entrypoint 相对复杂,专门定义了五个缓存调优变量(第 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 缓存池。默认配置将 44% 分配给 trie 缓存,24% 给快照,20% 给数据库,12% 给垃圾回收——这是针对 L2 工作负载优化的分配方案,因为 L2 场景下 trie 操作占主导地位。

Geth 还会根据相关变量是否已设置(使用第 33-51 行中的 ${VAR+x} 语法进行判断),有条件地追加 ethstats、非保护交易、状态方案及引导节点等参数。

Nethermind:简洁优雅

nethermind/nethermind-entrypoint 是三个客户端中最简洁的。其核心在于 --config 参数:

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

Nethermind 内置了各网络的配置文件,传入 --config=base-mainnet 即可激活所有链相关参数——创世块、分叉高度、Gas 限制——无需额外配置。这也是 Nethermind 不需要 RETH_CHAIN 或自定义初始化步骤的原因。

Reth:最复杂的路径

Reth 的入口脚本是三者中最为复杂的,值得单独深入分析。

Reth 的历史证明初始化

reth/reth-entrypoint 包含了整个代码库中最复杂的 shell 逻辑。除标准启动流程外,它还处理三个独特功能:日志级别转换、Flashblocks 支持,以及历史证明初始化。

日志级别转换

Reth 使用冗余标志(-v-vv-vvv 等)而非具名日志级别。入口脚本在第 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 这样的命名方式。

历史证明的多阶段启动

最值得关注的代码从第 75 行开始。当 RETH_HISTORICAL_PROOFS=true 时,Reth 无法直接启动并对外提供服务——它需要先初始化历史证明数据库,但这要求节点已同步到创世块之后。问题在于:Reth 不支持以只读模式启动旧版数据库。

解决方案是分三个阶段启动:

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

阶段一:同步到创世块之后。 Reth 以 & 方式在后台启动,仅绑定到本地的 HTTP 端口。脚本随即进入轮询循环,持续调用 eth_getBlockByNumber 并判断返回的块号是否已超过 0x0。超时时间设置为 6 小时(第 89 行60 * 60 * 6 秒),因为在大型数据库上,Reth 的静态文件管理器初始化可能极其缓慢。

阶段二:优雅关闭并初始化证明数据库。 一旦同步超过创世块,脚本向后台 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
}

这里通过轮询 /proc/$pid 来等待进程结束,而非使用 wait 命令——因为 wait 只能等待同一 shell 会话中的子进程,而该进程是以后台方式启动的。Reth 退出后,脚本运行 reth proofs init 来构建历史证明数据库。

阶段三:正式启动。 最后,通过 exec--proofs-history--proofs-history.storage-path 参数重新启动 Reth,脚本进程被替换。

提示: 6 小时的超时设置看似极端,但这是根据真实环境条件校准的。如果你使用的是机械硬盘或内存有限,静态文件管理器对约 2TB 的 Base 主网数据库进行初始扫描确实需要数小时。如果你使用 NVMe 固态硬盘且内存在 64GB 以上,通常几分钟内就能完成。

完整启动时间线

综合以上内容,以下是从 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 篇将正面比较三个客户端——它们的 Dockerfile、构建流水线、运行时配置,以及一个关键的非对称设计:base-consensus 仅随 Reth 镜像一同分发。