To achieve high availability and better performance, we could build a HAProxy load balancer in front of multiple Ethereum RPC providers, and also automatically adjust traffic weights based on the latency and block timestamp of each RPC endpoints.
Configurations
In haproxy.cfg, we have a backend named rpc-backend, and two RPC endpoints: quicknode and alchemy as upstream servers.
global
log stdout format raw local0 info
stats socket ipv4@*:9999 level admin expose-fd listeners
stats timeout 5s
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 10s
timeout client 60s
timeout server 60s
timeout http-request 60s
frontend stats
bind *:8404
stats enable
stats uri /
stats refresh 10s
frontend http
bind *:8000
option forwardfor
default_backend rpc-backend
backend rpc-backend
balance leastconn
server quicknode 127.0.0.1:8001 weight 100
server alchemy 127.0.0.1:8002 weight 100
frontend quicknode-frontend
bind *:8001
option dontlog-normal
default_backend quicknode-backend
backend quicknode-backend
balance roundrobin
http-request set-header Host xxx.quiknode.pro
http-request set-path /xxx
server quicknode xxx.quiknode.pro:443 sni str(xxx.quiknode.pro) check-ssl ssl verify none
frontend alchemy-frontend
bind *:8002
option dontlog-normal
default_backend alchemy-backend
backend alchemy-backend
balance roundrobin
http-request set-header Host xxx.alchemy.com
http-request set-path /xxx
server alchemy xxx.alchemy.com:443 sni str(xxx.alchemy.com) check-ssl ssl verify none
ref:
https://docs.haproxy.org/2.7/configuration.html
https://www.haproxy.com/documentation/hapee/latest/configuration/
Test it on local:
docker run --rm -v $PWD:/usr/local/etc/haproxy \
-p 8000:8000 \
-p 8404:8404 \
-p 9999:9999 \
-i -t --name haproxy haproxy:2.7.0
docker exec -i -t -u 0 haproxy bash
echo "show stat" | socat stdio TCP:127.0.0.1:9999
echo "set weight rpc-backend/quicknode 0" | socat stdio TCP:127.0.0.1:9999
# if you're using a socket file descriptor
apt update
apt install socat -y
echo "set weight rpc-backend/alchemy 0" | socat stdio /var/lib/haproxy/haproxy.sock
ref:
https://www.redhat.com/sysadmin/getting-started-socat
Healtchcheck
Then the important part: we're going to run a simple but flexible healthcheck script, called node weighter, as a sidecar container. So the healthcheck script can access HAProxy admin socket of the HAProxy container through 127.0.0.1:9999.
The node weighter can be written in any language. Here is a TypeScript example:
in HAProxyConnector.ts which sets weights through HAProxy admin socket:
import net from "net"
export interface ServerWeight {
backendName: string
serverName: string
weight: number
}
export class HAProxyConnector {
constructor(readonly adminHost = "127.0.0.1", readonly adminPort = 9999) {}
setWeights(serverWeights: ServerWeight[]) {
const scaledServerWeights = this.scaleWeights(serverWeights)
const commands = scaledServerWeights.map(server => {
return `set weight ${server.backendName}/${server.serverName} ${server.weight}\n`
})
const client = net.createConnection({ host: this.adminHost, port: this.adminPort }, () => {
console.log("HAProxyAdminSocketConnected")
})
client.on("error", err => {
console.log("HAProxyAdminSocketError")
})
client.on("data", data => {
console.log("HAProxyAdminSocketData")
console.log(data.toString().trim())
})
client.write(commands.join(""))
}
private scaleWeights(serverWeights: ServerWeight[]) {
const totalWeight = sum(serverWeights.map(server => server.weight))
return serverWeights.map(server => {
server.weight = Math.floor((server.weight / totalWeight) * 256)
return server
})
}
}
in RPCProxyWeighter.ts which calculates weights based a custom healthcheck logic:
import { HAProxyConnector } from "./connectors/HAProxyConnector"
import config from "./config.json"
export interface Server {
backendName: string
serverName: string
serverUrl: string
}
export interface ServerWithWeight {
backendName: string
serverName: string
weight: number
[metadata: string]: any
}
export class RPCProxyWeighter {
protected readonly log = Log.getLogger(RPCProxyWeighter.name)
protected readonly connector: HAProxyConnector
protected readonly ADJUST_INTERVAL_SEC = 60 // 60 seconds
protected readonly MAX_BLOCK_TIMESTAMP_DELAY_MSEC = 150 * 1000 // 150 seconds
protected readonly MAX_LATENCY_MSEC = 3 * 1000 // 3 seconds
protected shouldScale = false
protected totalWeight = 0
constructor() {
this.connector = new HAProxyConnector(config.admin.host, config.admin.port)
}
async start() {
while (true) {
let serverWithWeights = await this.calculateWeights(config.servers)
if (this.shouldScale) {
serverWithWeights = this.connector.scaleWeights(serverWithWeights)
}
this.connector.setWeights(serverWithWeights)
await sleep(1000 * this.ADJUST_INTERVAL_SEC)
}
}
async calculateWeights(servers: Server[]) {
this.totalWeight = 0
const serverWithWeights = await Promise.all(
servers.map(async server => {
try {
return await this.calculateWeight(server)
} catch (err: any) {
return {
backendName: server.backendName,
serverName: server.serverName,
weight: 0,
}
}
}),
)
// if all endpoints are unhealthy, overwrite weights to 100
if (this.totalWeight === 0) {
for (const server of serverWithWeights) {
server.weight = 100
}
}
return serverWithWeights
}
async calculateWeight(server: Server) {
const healthInfo = await this.getHealthInfo(server.serverUrl)
const serverWithWeight: ServerWithWeight = {
...{
backendName: server.backendName,
serverName: server.serverName,
weight: 0,
},
...healthInfo,
}
if (healthInfo.isBlockTooOld || healthInfo.isLatencyTooHigh) {
return serverWithWeight
}
// normalizedLatency: the lower the better
// blockTimestampDelayMsec: the lower the better
// both units are milliseconds at the same scale
// serverWithWeight.weight = 1 / healthInfo.normalizedLatency + 1 / healthInfo.blockTimestampDelayMsec
// NOTE: if we're using `balance source` in HAProxy, the weight can only be 100% or 0%,
// therefore, as long as the RPC endpoint is healthy, we always set the same weight
serverWithWeight.weight = 100
this.totalWeight += serverWithWeight.weight
return serverWithWeight
}
protected async getHealthInfo(serverUrl: string): Promise<HealthInfo> {
const provider = new ethers.providers.StaticJsonRpcProvider(serverUrl)
// TODO: add timeout
const start = Date.now()
const blockNumber = await provider.getBlockNumber()
const end = Date.now()
const block = await provider.getBlock(blockNumber)
const blockTimestamp = block.timestamp
const blockTimestampDelayMsec = Math.floor(Date.now() / 1000 - blockTimestamp) * 1000
const isBlockTooOld = blockTimestampDelayMsec >= this.MAX_BLOCK_TIMESTAMP_DELAY_MSEC
const latency = end - start
const normalizedLatency = this.normalizeLatency(latency)
const isLatencyTooHigh = latency >= this.MAX_LATENCY_MSEC
return {
blockNumber,
blockTimestamp,
blockTimestampDelayMsec,
isBlockTooOld,
latency,
normalizedLatency,
isLatencyTooHigh,
}
}
protected normalizeLatency(latency: number) {
if (latency <= 40) {
return 1
}
const digits = Math.floor(latency).toString().length
const base = Math.pow(10, digits - 1)
return Math.floor(latency / base) * base
}
}
in config.json:
Technically, we don't need this config file. Instead, we could read the actual URLs from HAProxy admin socket directly. Though creating a JSON file that contains URLs is much simpler.
{
"admin": {
"host": "127.0.0.1",
"port": 9999
},
"servers": [
{
"backendName": "rpc-backend",
"serverName": "quicknode",
"serverUrl": "https://xxx.quiknode.pro/xxx"
},
{
"backendName": "rpc-backend",
"serverName": "alchemy",
"serverUrl": "https://xxx.alchemy.com/xxx"
}
]
}
ref:
https://www.haproxy.com/documentation/hapee/latest/api/runtime-api/set-weight/
https://sleeplessbeastie.eu/2020/01/29/how-to-use-haproxy-stats-socket/
Deployments
apiVersion: v1
kind: ConfigMap
metadata:
name: rpc-proxy-config-file
data:
haproxy.cfg: |
...
config.json: |
...
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rpc-proxy
spec:
replicas: 2
selector:
matchLabels:
app: rpc-proxy
template:
metadata:
labels:
app: rpc-proxy
spec:
volumes:
- name: rpc-proxy-config-file
configMap:
name: rpc-proxy-config-file
containers:
- name: haproxy
image: haproxy:2.7.0
ports:
- containerPort: 8000
protocol: TCP
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 256Mi
volumeMounts:
- name: rpc-proxy-config-file
subPath: haproxy.cfg
mountPath: /usr/local/etc/haproxy/haproxy.cfg
readOnly: true
- name: node-weighter
image: your-node-weighter
command: ["node", "./index.js"]
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 256Mi
volumeMounts:
- name: rpc-proxy-config-file
subPath: config.json
mountPath: /path/to/build/config.json
readOnly: true
---
apiVersion: v1
kind: Service
metadata:
name: rpc-proxy
spec:
clusterIP: None
selector:
app: rpc-proxy
ports:
- name: http
port: 8000
targetPort: 8000
The RPC load balancer can then be accessed through http://rpc-proxy.default.svc.cluster.local:8000 inside the Kubernetes cluster.
ref:
https://www.containiq.com/post/kubernetes-sidecar-container
https://hub.docker.com/_/haproxy