启动序列深度解析:从 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=geth 且 USE_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_NETWORK 或 OP_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 镜像一同分发。