Read OSS

Base 节点架构:30 个文件如何编排一个以太坊 L2

中级

前置知识

  • Docker 和 Docker Compose 基础知识
  • 对以太坊 L1/L2 架构的基本理解(执行层与共识层)
  • 了解 OP Stack 是什么(Optimism 的 rollup 框架)

Base 节点架构:30 个文件如何编排一个以太坊 L2

大多数你克隆下来的代码库都是应用程序——有 src/ 目录、包管理器,以及能生成单个二进制文件的构建系统。base/node 仓库则完全不同。它是一个部署编排器——大约 30 个文件,负责从四个不同的仓库拉取、构建并串联上游二进制文件,横跨三种编程语言,最终产出一个可运行的 Base L2 以太坊节点。理解这一核心定位,是读懂整个仓库的关键所在。

本文是五篇深度解析系列的第一篇。我们将从整体上梳理整个代码库,讲解支撑一切的双服务架构,并展示一个 JSON 文件是如何驱动整条构建流水线的。

这个仓库到底是什么

如果你 git clone 这个仓库,期望找到区块链共识逻辑或 EVM 执行代码,你会大失所望。关键路径上没有 main()base/node 本质上是一个有明确主张的组合层,它做了这几件事:

  1. 精确锁定版本:在单个 JSON 文件中固定四个上游依赖(Reth、Geth、Nethermind 和 op-node)的确切版本
  2. 从源码构建:在 Docker 多阶段构建中完成编译,并通过验证 commit hash 来防范供应链攻击
  3. 串联各组件:通过入口脚本处理服务发现、JWT 认证和功能开关切换
  4. 编排启动流程:借助 Docker Compose 确保执行客户端就绪后,共识客户端再建立连接
flowchart LR
    subgraph "base/node repository"
        DC[docker-compose.yml]
        VJ[versions.json]
        EP[Entrypoint Scripts]
        DF[Dockerfiles]
    end
    subgraph "Upstream Repos"
        R[base/base<br/>Reth + base-consensus]
        G[ethereum-optimism/op-geth]
        N[NethermindEth/nethermind]
        O[ethereum-optimism/optimism<br/>op-node]
    end
    VJ -->|pins versions| DF
    DF -->|clones & builds| R
    DF -->|clones & builds| G
    DF -->|clones & builds| N
    DF -->|clones & builds| O
    DC -->|orchestrates| EP

最终,无论你选择哪个执行客户端,一条 docker compose up 命令就能启动一个正在同步中的 Base 节点。

目录结构与文件职责

整个仓库只有约 30 个文件,不会有迷失在深层目录树中的烦恼。但每个文件都有明确的职责,分别对应构建与运行流水线的不同阶段。

路径 职责 说明
docker-compose.yml 编排 定义两个服务及其连接关系
.env 用户配置 默认客户端选择和数据目录
.env.mainnet / .env.sepolia 网络配置 各网络的端点、bootnodes 和缓存设置
versions.json 版本锁定 所有上游依赖版本的唯一真实来源
versions.env 生成配置 可被 Dockerfile 直接 source 的 shell 导出变量
geth/Dockerfile 构建流水线 op-geth + op-node 的多阶段 Docker 构建
reth/Dockerfile 构建流水线 base-reth-node + base-consensus + op-node 的多阶段 Docker 构建
nethermind/Dockerfile 构建流水线 Nethermind + op-node 的多阶段 Docker 构建
geth/geth-entrypoint 运行时 Geth 启动脚本,含缓存调优
reth/reth-entrypoint 运行时 Reth 启动脚本,支持 Flashblocks 和历史证明
nethermind/nethermind-entrypoint 运行时 Nethermind 启动脚本
consensus-entrypoint 运行时 分发器,路由至 op-node 或 base-consensus
op-node-entrypoint 运行时 旧版共识客户端启动脚本
base-consensus-entrypoint 运行时 新版共识客户端启动脚本,支持 follow 模式
supervisord.conf 进程管理 用于独立 Docker 容器运行时的回退方案
dependency_updater/ 工具链 用于自动追踪版本更新的 Go CLI
.github/workflows/ CI/CD 构建、测试和发布自动化

提示: 扁平的目录结构是有意为之的。每个执行客户端都有自己的目录,其中恰好只有两个文件——一个 Dockerfile 和一个入口脚本。其他所有内容都放在根目录下。这使得添加第四个客户端变得非常简单:新建一个目录,添加两个文件,更新 versions.json 即可。

双服务架构

base/node 的核心是 docker-compose.yml——一个极为精简的文件,定义了整个运行时拓扑。

flowchart TB
    subgraph Docker["Docker Compose Network"]
        subgraph EX["execution service"]
            EC[Execution Client<br/>Reth / Geth / Nethermind]
        end
        subgraph ND["node service"]
            CC[Consensus Client<br/>op-node / base-consensus]
        end
    end
    CC -->|"Engine API<br/>port 8551<br/>JWT auth"| EC
    EC -->|"depends_on"| ND
    Internet -->|"P2P :30303"| EC
    Internet -->|"P2P :9222"| CC
    User -->|"RPC :8545"| EC

两个服务使用同一个 Dockerfile,在构建时通过 CLIENT 环境变量来选择:

# docker-compose.yml line 5
dockerfile: ${CLIENT:-geth}/Dockerfile

这意味着 docker compose build 会产出两个内容相同的镜像。差异化发生在运行时——通过 command 覆盖来实现:execution 服务运行 bash ./execution-entrypoint,而 node 服务运行 bash ./consensus-entrypoint

第 26 行depends_on 指令确保 Docker 优先启动执行服务。但 Docker 的 depends_on 只等待容器启动,并不等待容器内的应用就绪。真正的就绪协调发生在共识入口脚本中——脚本会轮询 Engine API,这一模式将在第二篇中详细介绍。

supervisord.conf 存在但不会被调用的原因

你会注意到每个 Dockerfile 都以 CMD ["/usr/bin/supervisord"] 结尾,同时还有一个 supervisord.conf,它可以在单个容器中同时运行两个进程。这是在不使用 Docker Compose 的情况下运行镜像时的回退方案——由 supervisord 在单个容器中管理两个进程。

当你使用 docker compose up 时,docker-compose.yml 中的 command 指令会覆盖每个 Dockerfile 中的 CMD。执行容器只运行执行入口脚本,节点容器只运行共识入口脚本。supervisord 永远不会被调用。

flowchart TD
    A{"How are you<br/>running it?"} -->|"docker compose up"| B["command overrides CMD<br/>Each service runs one process"]
    A -->|"docker run"| C["CMD runs supervisord<br/>Both processes in one container"]
    B --> D["execution-entrypoint"]
    B --> E["consensus-entrypoint"]
    C --> F["supervisord manages both<br/>execution + consensus"]

这种双模式设计是务实的选择。Docker Compose 提供了更好的日志输出、独立重启和资源隔离。但某些部署环境没有 Compose——此时带有 supervisord 的单容器方案同样好用。

三层配置体系

配置通过三个层次依次流动,后者优先级高于前者。理解这一层级关系,是排查"为什么我的节点连接到了错误的端点"此类问题的关键。

flowchart TD
    A[".env<br/>CLIENT=geth<br/>USE_BASE_CONSENSUS=true<br/>HOST_DATA_DIR=./geth-data"] -->|"lowest priority"| D["Effective Config"]
    B[".env.mainnet / .env.sepolia<br/>Network endpoints, bootnodes,<br/>cache settings, L1 config"] -->|"medium priority<br/>(loaded via env_file)"| D
    C["Shell environment<br/>export CLIENT=reth"] -->|"highest priority"| D

第一层:.env默认配置只有三行:

CLIENT=${CLIENT:-geth}
HOST_DATA_DIR=./${CLIENT}-data
USE_BASE_CONSENSUS=true

这将执行客户端默认设置为 Geth,并将 base-consensus 启用为默认共识客户端。

第二层:.env.mainnet / .env.sepolia — 通过 docker-compose.yml 第 19 行env_file 指令加载。NETWORK_ENV 变量默认指向 .env.mainnet。要切换到 Sepolia 测试网,设置 NETWORK_ENV=.env.sepolia 即可。

第三层:Shell 环境变量 — 在运行 docker compose up 之前在 shell 中设置的任何变量,优先级都高于上述两个文件。

双命名空间模式

网络配置文件中让人困惑的地方之一,是重复的变量命名空间。看看 .env.mainnet

OP_NODE_L2_ENGINE_RPC=http://execution:8551       # for op-node
BASE_NODE_L2_ENGINE_RPC=ws://execution:8551        # for base-consensus

同一个 Engine API 端点用两个不同的前缀定义了两次。这是因为 op-node 读取 OP_NODE_* 变量,而 base-consensus 读取 BASE_NODE_* 变量。两个客户端需要相同的信息,但使用了不同的环境变量命名约定。OP_NODE_* 命名空间是来自 Optimism 的旧版约定,BASE_NODE_* 则是 Base 专属的新版约定。

提示: 网络配置文件中硬编码的 JWT secret(688f5d737bad920bdfb2fc2f488d6b6209eebda1dae949a8de91398d932c517a)看起来像是安全隐患,但这是有意为之的。Engine API 仅在 Docker 网络内部暴露,无法从宿主机访问。JWT 的存在是为了满足 Engine API 规范的要求,而非用于抵御外部攻击者。

版本锁定:从 versions.json 到 Docker 构建

versions.json 文件是所有上游依赖的唯一真实来源:

{
  "base_reth_node": {
    "tag": "v0.7.0",
    "commit": "2b0d89d4267ae7b2893e1719d2ba026074e4a8b8",
    "owner": "base",
    "repo": "base",
    "tracking": "release"
  },
  "op_geth": {
    "tag": "v1.101702.0",
    "commit": "d0734fd5f44234cde3b0a7c4beb1256fc6feedef",
    "owner": "ethereum-optimism",
    "repo": "op-geth",
    "tracking": "release"
  }
}

每个条目记录了 tag(要克隆的 Git tag)、commit(该 tag 对应的预期 SHA)以及 tracking 模式(控制自动更新器如何发现新版本)。我们将在第四篇中深入探讨更新器。

关键在于 Docker 构建期间发生的两步验证。看看 Geth Dockerfile 第 9-11 行的克隆步骤:

RUN . /tmp/versions.env && git clone $OP_GETH_REPO --branch $OP_GETH_TAG --single-branch . && \
    git switch -c branch-$OP_GETH_TAG && \
    bash -c '[ "$(git rev-parse HEAD)" = "$OP_GETH_COMMIT" ]'

首先按指定 tag 克隆,然后验证 HEAD 是否与预期的 commit hash 匹配。如果上游维护者将某个 tag 指向了另一个 commit(即"tag 篡改攻击"),构建会立即失败。这是在 Docker 层面实现的供应链安全保障。

flowchart LR
    VJ[versions.json] -->|"dependency_updater"| VE[versions.env]
    VE -->|"COPY into build"| DF[Dockerfile]
    DF -->|"1. git clone at tag"| REPO[Upstream Repo]
    DF -->|"2. verify commit hash"| CHECK{SHA matches?}
    CHECK -->|Yes| BUILD[Build binary]
    CHECK -->|No| FAIL[Build fails]

versions.env 是一个自动生成的文件——包含可直接 source 的 shell 导出变量,供 Dockerfile 在构建时使用。每个依赖项按统一的命名规则生成三个变量:

export OP_GETH_TAG=v1.101702.0
export OP_GETH_COMMIT=d0734fd5f44234cde3b0a7c4beb1256fc6feedef
export OP_GETH_REPO=https://github.com/ethereum-optimism/op-geth.git

规则是将依赖键名转为大写(op_gethOP_GETH),再分别拼接 _TAG_COMMIT_REPO 后缀。这个文件已提交到仓库,并在 versions.json 发生变更时自动重新生成。

各组件如何协同

了解了各个独立组件后,让我们来看它们端到端是如何串联在一起的:

flowchart TD
    USER["Operator runs:<br/>CLIENT=reth docker compose up"] --> DC[docker-compose.yml]
    DC -->|"builds using"| RDF[reth/Dockerfile]
    RDF -->|"reads versions from"| VE[versions.env]
    VE -->|"generated from"| VJ[versions.json]
    RDF -->|"clones & builds"| RETH[base-reth-node binary]
    RDF -->|"clones & builds"| BC[base-consensus binary]
    RDF -->|"clones & builds"| OPN[op-node binary]
    DC -->|"starts execution service"| EX["reth-entrypoint<br/>→ base-reth-node"]
    DC -->|"starts node service"| ND["consensus-entrypoint<br/>→ base-consensus"]
    ND -->|"Engine API + JWT"| EX

整个流程是:用户选择客户端 → Docker Compose 触发对应的 Dockerfile → Dockerfile source versions.env 获取上游仓库和 commit 信息 → 从源码构建二进制文件 → 入口脚本处理运行时配置和服务间协调。

下一步

我们已经搭建好了架构骨架——两个服务、三层配置以及版本锁定的构建流水线。但骨架本身并不能告诉你容器实际启动时会发生什么。启动序列涉及轮询循环、公网 IP 探测、JWT secret 写入,而 Reth 的情况更为特殊,需要一个可能长达六小时的多阶段初始化流程。

第二篇将逐步追踪从 docker compose up 到节点完全同步的每一个环节,包括共识分发器模式以及 Reth 历史证明初始化的子流程。