web3-infra
一次 Shiden 节点降配后的双重故障:RocksDB 锁冲突与 litep2p panic
一次 Shiden archive RPC 节点降配复盘:backup 容器抢占 RocksDB 锁、旧主容器 litep2p panic,以及不删除链数据的恢复与验证清单。
这次故障从一次很普通的 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_syncState的currentBlock等于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 也确认没有其他锁持有者。这是第二个独立故障。
为了避免被第一种假设锚定,我把测试缩小成三个对照:
- 等待一段时间后,关闭自动重试,再冷启动旧主容器。
- 保持同一镜像、同一启动参数和同一 bind-mounted
/data。 - 只替换容器元数据与 Docker 网络状态。
冷启动旧容器仍能稳定复现 panic。删除并重建容器后,节点正常启动。
因此可以确认,链数据和镜像并不是恢复所必需的变量。更谨慎的表述是:证据指向旧容器运行时或网络状态异常,重建容器消除了它;仅凭这次现场还不能证明 litep2p 内部的精确根因。
重建容器,不重建数据库
这里能安全重建容器,是因为链数据来自 host bind mount,而不是容器 writable layer。 Docker 文档说明,bind mount 直接把宿主机目录挂进容器,容器重建后仍可重新挂载同一份 host 数据 [4]。
执行前必须先确认 mount:
docker inspect node-primary --format '{{json .Mounts}}'
确认 /data 的 Source 指向宿主机持久盘、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_chainId和eth_blockNumber都返回正常结果。
可复用的降配与恢复清单
降配前
- 记录实例类型、容器 ID、镜像 digest、启动参数和 mount。
- 记录
system_health、system_syncState和 EVM RPC 结果。 - 列出所有引用
/data的容器,不只看预期的主容器。 - 把 backup 容器的 restart policy 设置为
no。 - 确认数据盘不会随实例终止而删除;降配只做 stop/start,不做 replacement。
遇到 LOCK 错误
- 使用
lsof或fuser查实际锁持有者。 - 对比
pgrep、docker ps -a和docker 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,让监控产生“节点健康”的假象。
以后我会把节点恢复标准拆成四层:
- 主机和磁盘健康。
- 唯一进程持有数据库锁。
- parachain 与 relaychain 持续推进。
- 实际消费者需要的 RPC namespace 全部可用。
只有四层都通过,节点才算恢复。