web3-infra

一次 Shiden 节点降配后的双重故障:RocksDB 锁冲突与 litep2p panic

一次 Shiden archive RPC 节点降配复盘:backup 容器抢占 RocksDB 锁、旧主容器 litep2p panic,以及不删除链数据的恢复与验证清单。

Jul 15, 2026
ShidenSubstrateDockerRocksDBincident-recovery

这次故障从一次很普通的 EC2 降配开始。

Shiden archive RPC 节点在降配前已经追到链头,peer、RPC、容器和主机检查都正常。实例重启后,日志却先后出现了两个看起来有关、实际上需要分开处理的问题:

IO error: While lock file: /data/chains/shiden/db/full/LOCK:
Resource temporarily unavailable

锁释放以后,客户端又报了另一个错误:

Thread 'tokio-runtime-worker' panicked at
'SelectNextSome polled after terminated'

最后的修复没有删除 LOCK,没有 reset 数据库,也没有重做 4 TB 级别的链数据。真正有效的动作是:找出锁持有者,停掉错误启动的 backup 容器,再重建主容器本身,而不是重建它挂载的数据目录。

这是一份单次事故的现场记录,不代表所有 Astar 客户端版本都会有相同行为。文中的判断来自当时的进程、容器、RPC 和日志证据。

降配前,节点其实是健康的

降配前先做了四组检查:

  • EC2 system、instance 和 EBS status 都是正常状态。
  • Docker 主容器持续运行,没有 restart,也没有 OOM。
  • system_health 返回 peer 非零且 isSyncing: false
  • system_syncStatecurrentBlock 等于 highestBlock,日志也持续 import 新块。

这一步很重要。 如果没有降配前的健康快照,重启后的 LOCK 错误很容易被误判为“旧数据库本来就坏了”。

降配会中断进程、Docker daemon 和网络连接。它不会主动改变 bind mount 里的链数据,但会把所有容器的 restart policy、启动顺序和旧运行状态重新带进故障面。

第一个故障:backup 容器抢到了 RocksDB 锁

主容器启动时连续报错:

Error: Service(Client(Backend(
  "IO error: While lock file: /data/chains/shiden/db/full/LOCK:
   Resource temporarily unavailable"
)))

RocksDB 的数据库目录只能由一个进程打开。RocksDB 会通过操作系统锁阻止第二个进程同时使用同一数据库 [1]

所以这里的第一个问题不是“怎样删除 LOCK”,而是“谁正在持有 LOCK”。

先列出容器和 collator 进程:

docker ps -a --format 'Name={{.Names}} Status={{.Status}}'
pgrep -af astar-collator

再直接查锁持有者:

lsof /data/chains/shiden/db/full/LOCK

# lsof 不可用时也可以用 fuser
fuser -v /data/chains/shiden/db/full/LOCK

现场结果很明确:持锁 PID 不属于预期的 node-primary,而属于一个以前留下的 node-backup。这个 backup 容器原本只是一次恢复操作留下的备份,但它仍保留 restart policy;主机重启后,它处于 running 状态,并先打开了同一个 /data

Docker 的 restart policy 决定容器在退出或 daemon 生命周期变化时如何恢复。不同 policy 的语义不同,不能只凭容器名字里有 backup 就假设它不会运行 [2]

为什么节点看起来仍然“部分正常”

这次最容易误导人的地方是:RPC 并没有完全失效。

当时执行 system_health,仍然能拿到类似结果:

{
  "peers": 9,
  "isSyncing": false,
  "shouldHavePeers": true
}

system_syncState 也显示当前高度等于最高高度,backup 容器的日志还在导入 Shiden parachain 和 Kusama relaychain 新块。

如果检查到这里就结束,会得出“虽然主容器有错误,但服务正常”的错误结论。

继续调用 EVM RPC:

curl -sS -H 'Content-Type: application/json' \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' \
  http://127.0.0.1:9944

返回的是:

{
  "error": {
    "code": -32601,
    "message": "Method not found"
  }
}

原因在启动参数里。backup 能提供 Substrate RPC,却没有正式主容器的 --enable-evm-rpc。 Astar 文档也明确写着,EVM RPC 默认关闭,需要显式开启这个 flag [3]

这给了我一个很实用的检查原则:

一个 RPC 方法健康,不代表同一端口上的另一组 RPC namespace 也健康。

先解决锁冲突,但不碰链数据

锁冲突的安全处理顺序是:先让 backup 失去自动启动能力,再优雅停止它,然后确认锁真的释放。

先确认它当前的状态和 policy:

docker inspect node-backup \
  --format 'Running={{.State.Running}} Policy={{.HostConfig.RestartPolicy.Name}}'

禁用自动重启并停止:

docker update --restart=no node-backup
docker stop --timeout 120 node-backup

然后重新检查锁:

lsof /data/chains/shiden/db/full/LOCK

只有在没有进程持锁以后,才能启动另一个指向同一 datadir 的容器。

不要直接删除 LOCK 文件。 如果另一个 RocksDB 进程还活着,删除文件并不能让两个 writer 安全共享数据库,只会隐藏最有价值的诊断证据。

第二个故障:锁释放后出现 litep2p panic

锁释放以后,主容器不再报 Resource temporarily unavailable。但它仍然没有稳定启动,而是在 litep2p 网络初始化后退出:

[Relaychain] Running litep2p network backend
[Relaychain] Essential task `basic-block-import-worker` failed.
Thread 'tokio-runtime-worker' panicked at
'SelectNextSome polled after terminated'

这一刻不能说“LOCK 问题还没修好”。原始错误已经消失,lsof 也确认没有其他锁持有者。这是第二个独立故障。

为了避免被第一种假设锚定,我把测试缩小成三个对照:

  1. 等待一段时间后,关闭自动重试,再冷启动旧主容器。
  2. 保持同一镜像、同一启动参数和同一 bind-mounted /data
  3. 只替换容器元数据与 Docker 网络状态。

冷启动旧容器仍能稳定复现 panic。删除并重建容器后,节点正常启动。

因此可以确认,链数据和镜像并不是恢复所必需的变量。更谨慎的表述是:证据指向旧容器运行时或网络状态异常,重建容器消除了它;仅凭这次现场还不能证明 litep2p 内部的精确根因。

重建容器,不重建数据库

这里能安全重建容器,是因为链数据来自 host bind mount,而不是容器 writable layer。 Docker 文档说明,bind mount 直接把宿主机目录挂进容器,容器重建后仍可重新挂载同一份 host 数据 [4]

执行前必须先确认 mount:

docker inspect node-primary --format '{{json .Mounts}}'

确认 /dataSource 指向宿主机持久盘、Destination 也是 /data 后,才删除旧容器:

docker rm node-primary

下面是这次恢复命令的公开化版本。96g 是当时按降配后实例资源设定的限制,不应该直接复制到别的机器:

docker run -d \
  --name node-primary \
  --restart=on-failure:3 \
  --memory=96g \
  --memory-swap=96g \
  -p 30333:30333 \
  -p 9944:9944 \
  --mount type=bind,src=/data,dst=/data \
  staketechnologies/astar-collator:latest \
  astar-collator \
  --pruning archive \
  --blocks-pruning archive \
  --enable-evm-rpc \
  --name shiden-rpc \
  --chain shiden \
  --base-path /data \
  --rpc-external \
  --rpc-methods Safe \
  -- \
  --database paritydb \
  --sync warp \
  --state-pruning 256 \
  --blocks-pruning 256

如果新容器不能通过健康检查,回退动作也不应该碰 /data:停止新容器,在确认锁释放后,重新启动已知可工作的旧容器。

如何证明节点真的恢复了

我把原始报错本身当成回归测试,而不是只看 docker start 是否返回成功。

先检查容器状态:

docker inspect node-primary \
  --format 'Running={{.State.Running}} Restarts={{.RestartCount}} OOM={{.State.OOMKilled}}'

再确认锁属于主容器里的 collator:

lsof /data/chains/shiden/db/full/LOCK

扫描原始错误:

docker logs --since 10m node-primary 2>&1 | \
  grep -E 'While lock file|SelectNextSome polled after terminated|Essential task.*failed'

没有匹配后,再检查 Substrate RPC:

curl -sS -H 'Content-Type: application/json' \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"system_health","params":[]}' \
  http://127.0.0.1:9944

curl -sS -H 'Content-Type: application/json' \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"system_syncState","params":[]}' \
  http://127.0.0.1:9944

最后检查 EVM RPC:

curl -sS -H 'Content-Type: application/json' \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' \
  http://127.0.0.1:9944

curl -sS -H 'Content-Type: application/json' \
  --data-binary '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \
  http://127.0.0.1:9944

这次最终的绿灯信号是:

  • 主容器持续 running,restart count 为 0,OOM 为 false。
  • backup 全部 stopped,restart policy 为 no
  • RocksDB 锁由主容器进程持有。
  • isSyncing 为 false,currentBlock 等于 highestBlock
  • Shiden 和 Kusama 日志都持续 import 新块。
  • eth_chainIdeth_blockNumber 都返回正常结果。

可复用的降配与恢复清单

降配前

  • 记录实例类型、容器 ID、镜像 digest、启动参数和 mount。
  • 记录 system_healthsystem_syncState 和 EVM RPC 结果。
  • 列出所有引用 /data 的容器,不只看预期的主容器。
  • 把 backup 容器的 restart policy 设置为 no
  • 确认数据盘不会随实例终止而删除;降配只做 stop/start,不做 replacement。

遇到 LOCK 错误

  • 使用 lsoffuser 查实际锁持有者。
  • 对比 pgrepdocker ps -adocker inspect
  • 不删除 LOCK,不同时启动两个 writable datadir 使用者。
  • 先停止错误容器,再验证锁释放。

主容器仍然失败

  • 把新的错误签名和 LOCK 错误分开记录。
  • 关闭自动重试,做一次受控冷启动,避免 crash loop 淹没日志。
  • 检查 OOM、exit code、Docker events 和 panic 前的第一条 fatal/essential-task 日志。
  • 在 mount 已确认的前提下,优先重建容器元数据,而不是重建链数据。

恢复后

  • 同时验证 Substrate RPC 和 EVM RPC。
  • 连续观察 block import,而不是只采样一次高度。
  • 检查 restart count、OOM、内存、磁盘和锁持有者。
  • 保留回退容器可以,但必须保持 stopped 和 restart=no

这次事故真正暴露的问题

这次事故不是“降配导致数据库坏了”。它暴露的是容器生命周期管理没有把 backup 当成真正的非运行制品。

容器改名以后,restart policy 仍在;共享 datadir 以后,任何一个意外启动的容器都能改变主容器的启动结果。更麻烦的是,backup 还能提供一部分 RPC,让监控产生“节点健康”的假象。

以后我会把节点恢复标准拆成四层:

  1. 主机和磁盘健康。
  2. 唯一进程持有数据库锁。
  3. parachain 与 relaychain 持续推进。
  4. 实际消费者需要的 RPC namespace 全部可用。

只有四层都通过,节点才算恢复。

参考文献

  1. RocksDB Wiki: Basic Operations
  2. Docker Docs: docker container run — Restart policies
  3. Astar Docs: Node Commands;另见 Your Own RPC Server
  4. Docker Docs: Bind mounts