众所周知, RSS3 自己的 L2 主网已经上线稳定运营了一段时间了,我想从 DevOps 的角度和大家分享一下如何运行自己的 L2 网络的故事,以及如何和云原生 Kubernetes 集成。
文中提到的各种 chart 已经在 rss3-network org 上开源
https://github.com/RSS3-Network/helm-charts
Optimism Node Architecture#
用一张图说明如下
Optimism (以下简称 OP) 的 L2 Network,主要是由 op-geth
和 op-node
组成。和 L1 节点相同的是, geth 仍然用于存储数据和提供 RPC 响应;不同的是,由 op-node 来构建整个去中心化的网络,和决定数据的同步。
Cloudnative upgrade#
可能是我孤陋寡闻,也不清楚其他家的 L2 RPC 的云原生部署方式,并且 github 上发现其他的基于 op 的 helm chart 都不是很好用,所以就开始自己编写了一套 VSL 的 chart 图表 (理论上可以用于其他 L2 链的部署,但是未经任何测试)
Q1 security auth with JWT#
op-node 和 op-geth 必须是 1 <-> 1 对应的模式,所以采用了 Kubernetes Sidecar 模式, 它们作为一个 RPC Pod 的两个 container,共用一个 network namespace,可以通过 127.0.0.1 进行互相访问。
在 op-geth 的环境变量里,有一个 GETH_AUTHRPC_JWTSECRET
,相应的在 op-node 的环境变量里有一个 OP_NODE_L2_ENGINE_AUTH
需要配置,这两个容器通过一个相同的 jwt secret 进行通信。
上面已经讲到,使得这个 auth endpoint 永远在 127.0.0.1, 端口也只开放 localhost 的来解决这个问题。不仅如此, jwt token 的值的管理也是一个问题,因此我们采用了 initContainer 的命令 openssl rand -hex 32 > {{ .Values.jwt.path }}
来在每一次 Pod 创建时随机生成这个问题,这样不仅做到了 network namespace 上的安全,也做到了 secret 上的随机安全。
Q2 idempotent op-node peer id#
在 Q1 中讲到了,我们可以用 openssl rand 命令,在每个 pod 里都随机生成一串随机的字符串作为 jwt secret, 这使得 op-geth 和 op-node 的通信更加安全。
然而,op-node 有一个 peer id,其他的节点可以通过 固定的 OP_NODE_P2P_STATIC
来设置初始同步节点的地址。作为节点的运营方,自然是需要给每个节点一个固定的 peer id 的。
做到完全随机很容易,但是做到幂等,又不增加复杂度也是一个问题。经过研究发现,只要 op-node 的环境变量 OP_NODE_P2P_PRIV_PATH
不变,那么 peer id 就不会变。问题从固定一个 peer id ,变成固定一个文件的内容了。
自然的我们可以通过写一大串环境变量,例如 POD_PRIV_0 到 POD_PRIV_N 来解决问题。 但是这不优雅,没有办法快速的创建一个新的 pod ,每次都需要先新建一个环境变量,并且会被注入到每一个容器,因为 statefulset replicas 没有办法对单独的 pod 进行独立的设置。
幸运的是, openssl 支持传入参数作为随机数种子,那么我们只要保证随机数种子相同,生成出来的文件内容也会相同,自然就能做到幂等了。那么在一个 statefulset 里,有什么东西是每一个 pod 不同的呢, 没错就是 pod 本身的信息, pod 的 name 和 namespace 等。于是我们就可以通过 pod 的信息,配合 kubernetes downward api,生成一个幂等的 seed
部分配置如下
- name: generate-key
image: openquantumsafe/openssl3
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: P2P_ROOT_KEY
valueFrom:
secretKeyRef:
key: {{ .Values.node.p2p.generateSecretKey }}
name: {{ .Values.node.p2p.generateSecretName }}
- name: P2P_GENERATE_KEY
value: $(P2P_ROOT_KEY)-$(POD_NAMESPACE)-$(POD_NAME)
command: ["/bin/sh", "-c"]
args:
- |
echo ${P2P_GENERATE_KEY} | openssl sha256 -binary | xxd -p -c 32 > {{ .Values.node.p2p.privateKeyPath }};
openssl rand -hex 32 > {{ .Values.jwt.path }};
这里引入了一个新的环境变量,P2P_ROOT_KEY
,是为了保证如果有多个集群,多个相同的环境下, peer id 不会冲突
好了,经过如此操作,我们就能保证,我们每一个 L2 RPC 的 Pod, Peer id 都是唯一,并且调度 Pod 不会导致 id 变化。
Q3 HA sequencer#
第三个问题,由于 L2 网络里只能有一个活跃的 sequencer 负责出块,所以整个 sequencer 的高可用和出块设置也是一个比较头疼的问题。
这便是 VSL-Reconcile的由来了。
首先,我们会部署多个开启 sequencer 和 admin-api 的 sequencer 节点,但是默认都是处于停止出块的状态。然后将由 reconcile 决定哪一个节点作为当前的出块节点,并且把 sequencer 域名的流量切到出块节点。
这一部分,我参考了 Vault HA 的模式
Vault 是一个 secret manager, 高可用模式下,只有一个实例是 active 的,其他的都是 standby。因此我们的 sequencer 节点也是如此。
参考 Vault 的 Kubernetes 服务发现模式,我们也给 sequencer pod 打 label 来控制 pod 的调度和流量切换。
首先,我们会给所有的 sequencer pod 打上 vsl.rss3.io/synced=false
的标签,这意味着区块同步没有跟上进度。
然后会检查当前区块的同步进度,把标签更改成 vsl.rss3.io/synced=true
并且,service 只会选择 vsl.rss3.io/synced
为 true
的 Pod 作为负载,这样使得由于 RPC 请求流量过高,需要增加 Pod 副本数量时,新创建的 Pod 不会被放入请求中,因为它们还没有同步完毕。
然后我们会挑选所有 vsl.rss3.io/synced=true
的 sequencer Pod,打上 vsl.rss3.io/active=false
的 label,这个标记意味着,这个 Pod 可以成为出块节点。
当 reconcile 开启某一个节点的出块功能后, vsl.rss3.io/active
会变成 true
, sequencer 域名的 loadbalancer 也带有 label selector vsl.rss3.io/active=true
,这样无论哪一个 Pod 作为当前的出块节点,都能够正确、自动地把流量路由到 Pod.