Розробка платформи Node-as-a-Service
Запустити блокчейн-ноду вручну — просто. Запустити їх сотнями з гарантією uptime, версіонуванням, ізоляцією клієнтів, біллінгом та API-прокси — це повнофункціональний інфраструктурний продукт. Саме таку платформу будують ті, хто хоче конкурувати з Infura, Alchemy, QuickNode або пропонувати керовану інфраструктуру enterprise-клієнтам у конкретному регіоні або екосистемі.
Перш ніж почати розробку, чесно відповідьте на запитання: ви будуєте NaaS для публічних блокчейнів (Ethereum, Solana, BNB) чи для приватних/дозволених мереж (Hyperledger Besu, Quorum)? Це архітектурно різні системи з різними проблемами.
Рівні архітектури платформи NaaS
Рівень оркестрації нод
Kubernetes — стандарт для управління життєвим циклом нод. Але стандартний K8s deployment не підходить для блокчейн-нод безпосередньо: ноди мають величезні stateful дані, потребують специфічних network policies, а перезапуск pod = повторна синхронізація з нуля (яка займає дні).
StatefulSet з PVC:
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ethereum-geth
spec:
serviceName: "geth"
replicas: 1
selector:
matchLabels:
app: ethereum-geth
template:
spec:
containers:
- name: geth
image: ethereum/client-go:v1.13.14
args:
- "--datadir=/data"
- "--http"
- "--http.addr=0.0.0.0"
- "--http.vhosts=*"
- "--http.api=eth,net,web3,txpool"
- "--ws"
- "--ws.addr=0.0.0.0"
- "--maxpeers=50"
- "--cache=4096"
ports:
- containerPort: 8545 # HTTP RPC
- containerPort: 8546 # WebSocket
- containerPort: 30303 # P2P
protocol: TCP
- containerPort: 30303
protocol: UDP
volumeMounts:
- name: data
mountPath: /data
resources:
requests:
memory: "16Gi"
cpu: "4"
limits:
memory: "32Gi"
cpu: "8"
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "fast-nvme"
resources:
requests:
storage: 3Ti # Ethereum archive node
Проблема P2P портів у K8s: блокчейн-ноди вимагають фіксованих портів для peer discovery (30303/TCP+UDP для Ethereum). NodePort або LoadBalancer з фіксованим портом на кожну ноду — єдиний робочий підхід. HostNetwork — альтернатива, але втрачається ізоляція.
Bootstrap: проблема "першого дня"
Синхронізація Ethereum mainnet з нуля (snap sync): 12–24 години. Archive node: 2–5 тижнів. Неприйнятно для платформи, де клієнт платить з першої хвилини.
Рішення:
Snapshot distribution — ініціалізація ноди з актуального снапшоту бази даних. Потрібно зберігати актуальні снапшоти (~500GB–3TB залежно від типу) та надавати HTTP/S3-доступ для bootstrap. Стратегія: снапшот кожні 7 днів, інкрементальні діффи щодня.
#!/bin/bash
# Bootstrap нода з снапшоту
NODE_ID=$1
CHAIN=$2
SNAPSHOT_BASE="s3://your-snapshots/${CHAIN}/latest"
# Завантажуємо снапшот
aws s3 sync ${SNAPSHOT_BASE} /data/${NODE_ID}/chaindata \
--no-sign-request \
--region eu-west-1
# Перевіряємо цілісність
sha256sum -c /data/${NODE_ID}/chaindata/CHECKSUM
# Запускаємо ноду
kubectl rollout restart statefulset/${CHAIN}-node-${NODE_ID}
Firehose / Erigon snapshots — для деяких цепей спільнота підтримує публічні снапшоти (Erigon завантажує снапшоти в BitTorrent/IPFS).
API Gateway та маршрутизація
Клієнт отримує один endpoint. За ним — load balancer, health checks, rate limiting, управління API ключами.
Client → API Gateway (Kong/custom) → Node Pool → Blockchain Node
↓
[Auth, RateLimit, Billing, Logging]
Кастомний RPC прокси необхідний тому що:
- Потрібно фільтрувати небезпечні методи (
debug_traceTransaction— дорогостійкий, тільки для premium) - Потрібна маршрутизація за типом запиту (archive requests → archive node, latest block → sync node)
- Потрібне кешування відповідей для частих запитів (
eth_chainId,eth_blockNumber)
// Приклад RPC прокси з логікою маршрутизації
package proxy
type RPCRouter struct {
archivePool NodePool
fullNodePool NodePool
cacheClient *redis.Client
}
var archiveMethods = map[string]bool{
"eth_getBalance": true, // з block parameter != "latest"
"eth_call": true,
"eth_getStorageAt": true,
"trace_call": true,
"trace_replayTransaction": true,
}
func (r *RPCRouter) Route(req *RPCRequest) NodePool {
if archiveMethods[req.Method] {
if req.RequiresHistoricalBlock() {
return r.archivePool
}
}
return r.fullNodePool
}
func (r *RPCRouter) Handle(w http.ResponseWriter, req *RPCRequest, apiKey string) {
// Перевіряємо кеш
cacheKey := req.CacheKey()
if cached, err := r.cacheClient.Get(ctx, cacheKey).Bytes(); err == nil {
w.Write(cached)
return
}
pool := r.Route(req)
node := pool.GetHealthyNode()
resp := node.Forward(req)
if req.IsCacheable() {
r.cacheClient.Set(ctx, cacheKey, resp, req.CacheTTL())
}
// Біллінг: записуємо використання
r.billing.RecordRequest(apiKey, req.Method, resp.ComputeUnits())
w.Write(resp)
}
Health checking та failover
Блокчейн-нода може бути технічно "живою" (відповідає на ping), але практично непридатною (відстала на 100 блоків від мережі або sync mode = syncing).
type NodeHealthChecker struct {
client *ethclient.Client
}
func (h *NodeHealthChecker) IsHealthy(ctx context.Context) (bool, error) {
// Перевіряємо, чи не в режимі синхронізації
syncing, err := h.client.SyncProgress(ctx)
if err != nil {
return false, err
}
if syncing != nil {
return false, fmt.Errorf("node is syncing: %d/%d",
syncing.CurrentBlock, syncing.HighestBlock)
}
// Перевіряємо свіжість блоку
header, err := h.client.HeaderByNumber(ctx, nil)
if err != nil {
return false, err
}
blockAge := time.Since(time.Unix(int64(header.Time), 0))
if blockAge > 2*time.Minute {
return false, fmt.Errorf("block too old: %v", blockAge)
}
return true, nil
}
Health checks потрібно запускати кожні 10–30 секунд. Нода виключається з пула при двох послідовних невдачах, повертається після трьох успішних.
Мультитенантність та ізоляція
Розподіл ресурсів
Три моделі:
Shared nodes — декілька клієнтів використовують одну ноду. Дешево, але без гарантій продуктивності. Підходить для free tier та малих проектів.
Dedicated nodes — одна нода на клієнта. Гарантовані ресурси, ізоляція. Для enterprise.
Node clusters — декілька реплік за load balancer для одного клієнта. Для вимог high-availability.
-- Схема біллінгу
CREATE TABLE api_keys (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
key_hash BYTEA NOT NULL, -- ніколи не зберігаємо ключ у відкритому вигляді
tier VARCHAR(20) NOT NULL, -- free, starter, pro, enterprise
rate_limit_rps INTEGER NOT NULL,
monthly_cu_limit BIGINT, -- compute units
node_type VARCHAR(20) NOT NULL, -- shared, dedicated
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE usage_records (
id BIGSERIAL PRIMARY KEY,
api_key_id UUID NOT NULL REFERENCES api_keys(id),
method VARCHAR(100) NOT NULL,
chain_id INTEGER NOT NULL,
compute_units INTEGER NOT NULL,
response_time_ms INTEGER,
recorded_at TIMESTAMPTZ DEFAULT NOW()
);
-- Індекс для біллінгу за період
CREATE INDEX idx_usage_billing ON usage_records (api_key_id, recorded_at);
Compute Units (CU) — стандартна одиниця біллінгу в NaaS. Кожен RPC метод має вагу:
-
eth_blockNumber→ 10 CU -
eth_getTransactionReceipt→ 15 CU -
eth_call→ 26 CU -
trace_replayTransaction→ 75 CU -
eth_getLogs→ 75 CU + 1 CU за кожен повернений log
Rate limiting
Redis-based sliding window краще ніж token bucket для RPC навантажень:
func (rl *RateLimiter) Allow(ctx context.Context, apiKey string, rps int) (bool, error) {
now := time.Now().UnixMilli()
window := int64(1000) // 1 секунда в мілісекундах
pipe := rl.redis.Pipeline()
pipe.ZRemRangeByScore(ctx, apiKey, "0",
strconv.FormatInt(now-window, 10))
pipe.ZCard(ctx, apiKey)
pipe.ZAdd(ctx, apiKey, redis.Z{Score: float64(now), Member: now})
pipe.Expire(ctx, apiKey, 2*time.Second)
results, err := pipe.Exec(ctx)
count := results[1].(*redis.IntCmd).Val()
return count < int64(rps), nil
}
Мониторинг та алертинг
Критичні метрики
# Prometheus метрики для NaaS
- node_sync_lag_blocks{chain, node_id} # відставання від head
- node_peer_count{chain, node_id} # кількість пірів
- rpc_request_duration_seconds{method, status} # p50, p95, p99
- rpc_requests_total{method, chain, tier} # для біллінгу
- node_restart_total{chain, node_id, reason} # частота перезапусків
- compute_units_consumed{api_key, chain} # біллінгові дані
Правила алертів для on-call:
| Метрика | Поріг | Severity |
|---|---|---|
| sync_lag_blocks | > 10 блоків | Warning |
| sync_lag_blocks | > 50 блоків | Critical |
| peer_count | < 5 | Warning |
| rpc_error_rate | > 5% за 5 хв | Warning |
| node_restart_total | > 3 за годину | Critical |
Підтримувані клієнти та їхня специфіка
| Цепь | Клієнт | Розмір даних | Особливості |
|---|---|---|---|
| Ethereum (full) | Geth / Reth | ~1.2 TB | snap sync доступний |
| Ethereum (archive) | Erigon | ~2.5 TB | trace API відрізняється від Geth |
| Solana | Agave (Solana Labs) | ~50 TB (full ledger) | geyser plugin для стрімінгу |
| BNB Chain | BSC Geth fork | ~800 GB | швидший час блоку (3s) |
| Polygon | Bor + Heimdall | ~600 GB | два процеси на одну ноду |
| Arbitrum | Nitro | ~1 TB | sequencer feed, не P2P |
| Base | op-geth | ~800 GB | OP Stack, op-node поруч |
Reth — новий клієнт Ethereum на Rust від Paradigm. Значно швидше Geth при синхронізації (~2× менше часу), краще використання ресурсів. Для нових деплоїв — перший вибір для Ethereum full nodes.
Етапи розробки
Фаза 1 — Core infrastructure (4–6 тижнів): K8s setup, StatefulSet шаблони для 2–3 цепей, pipeline bootstrap зі снапшотів, базовий health checker.
Фаза 2 — API Gateway (3–4 тижні): RPC прокси, управління API ключами, rate limiting, підрахунок compute units.
Фаза 3 — Multi-tenancy & billing (3–4 тижні): ізоляція тенантів, відстеження використання, інтеграція біллінгу (Stripe), дашборд використання.
Фаза 4 — Observability (2–3 тижні): Prometheus + Grafana, алертинг, агрегація логів (Loki), on-call runbooks.
Фаза 5 — Self-service portal (4–6 тижнів): веб-інтерфейс для створення нод, перегляду метрик, управління API ключами.
Всього: 16–23 тижні до production-ready платформи. Додавання кожної нової цепи після запуску — 1–2 тижні (шаблон + pipeline зі снапшотами + тестування).







