fleet-router

Documentation / Operations · v0.8.1

Running in production

For a step-by-step archive deployment, follow the frozen SHiP guide: prepare immutable logs, run Hyperion Tools archive servers, and configure one stitched endpoint with live nodes.

View source on GitHub

On this page

How routing and failover work

A client connects over WebSocket. In a pool listener (identical full nodes, the default), the router selects an upstream, forwards that upstream's ABI to the client, and then proxies frames in both directions. A stitched listener presents live nodes and frozen range servers as one node; see Range-split fleets.

fleet-router is written in Rust on tokio and tokio-tungstenite, and uses rs_abieos (pure-Rust backend) to check that an upstream SHiP ABI parses. Each handshake, failover, and monitor builds a short-lived AbiHandle and drops it; connections do not share an abieos context. Upstream TLS uses rustls with the aws-lc-rs provider.

Range-split fleets

A range-split fleet serves old history and the chain tip from different upstreams:

A listener with an enabled frozen server is stitched (topology). The router presents its upstreams to clients as one SHiP node whose history is the union of their ranges, so a client reads from the first archived block to the head through one connection. With the default history_preference, blocks that an archive covers are read from the archive, not from a live node. With live servers configured, tip readiness (/ready) does not depend on the archives.

example.stitched.config.json is a complete config with two archived ranges, two replicas each, and two live nodes.

What clients see

The fleet head and LIB come from the online live nodes that are not stale or disabled by an operator (all online ones when every one is stale), including the blocks sessions stream from them, so they are fresher than the monitor's status interval. The fleet LIB never goes back. While every live node is offline, the last known values stay. A listener with no live server reports the newest archived block as both.

Deploying frozen range servers

  1. Archive only irreversible blocks. A range must end at or below the LIB of the node its logs came from, when they were copied. The router cannot check this inside a range; it checks it at seams (see Seams and handoffs) and warns once per archive when an archive serves blocks above the LIB of the live nodes (frozen upstream serves blocks above the fleet LIB).
  2. Keep the block log with the state-history logs, so requests with fetch_block get blocks from the archive (see fetch_block and block logs), and so its chain API can serve blocks.
  3. Run two replicas of each range on separate hosts. Sessions on a failed archive resume on its replica at the next block. Sessions spread over replicas by connections per weight.
  4. Declare each archive with "role": "frozen", its range and a max_sessions. The declared range lets the listener answer status and plan routes while the archive is down or still starting.
  5. Overlap the live tier. The live nodes' first block should be at or below the highest archive end, so no block falls between the archives and the tip. fleet_router_fleet_live_overlap_blocks is that overlap; a negative value is a hole. An overlap of 0, where the live node's first block follows the last archived one, is enough (see Cut-over from a snapshot).
  6. Pin chain_id. A stitched listener requires it.
  7. Check the coverage. fleet-router config test --strict <path> probes every server and prints, per log, the coverage the listener adds up to and its holes, and each upstream's ranges with where they came from (reported, declared or unknown). It exits 1 on a hole in the trace or chain-state coverage, a frozen server that did not report its ranges, or, with a chain API, archived blocks that no frozen server with an api_endpoint covers.

Sizing. A session holds at most one link to an archive while it streams, plus, during the last predial_blocks blocks of a leg, a standby link to the next leg's upstream. Both count toward max_sessions, and so do dials in progress. Set max_sessions at or below the archive's own session limit: a full archive is skipped, and a session waits in the router for it (see Holds, waits and gaps). An archive that answers the WebSocket upgrade with HTTP 503 is busy: that session avoids it for 2 s, and it is never marked offline. A finished link to an archive is kept for 30 s, so a client that asks for the next range on the same connection reuses the socket.

What an archive upstream must do. The router relies on an archive upstream to:

Toward an archive, the router requests only blocks inside its range (or below the fleet's first block, see below), up to the fleet head + 1 (LIB + 1 with irreversible_only), sends no have_positions, closes a finished link with Close 1000, and never sends status on a session link: its monitor connection carries status.

Cut-over from a snapshot

A common way to split a node's history is to archive its logs, then start a live node from a snapshot of the same chain. With the snapshot at block S:

Seams and handoffs

Holds, waits and gaps

When no upstream can serve the cursor now, a stitched session does not close at once:

Failover and strikes

This table applies to pool and stitched listeners alike. A strike counts against the upstream at the session's next block: it adds to fleet_router_upstream_block_failures_total, to the quarantine strikes, and to the session's budget of five such ends at one block (see Corrupted SHiP logs). Only unclean drops, frozen stalls and frozen contract violations strike.

How a link ended Strike Pause before the next link Afterwards
Unclean drop: the stream ended with no Close frame yes 100 ms, doubling to 3.2 s at the same block prefers another upstream
Close frame no 100 ms
idle_timeout_ms of silence while owed no 100 ms
upstream_stall_timeout_ms of silence while owed, live upstream, when another upstream can serve the next block (with none, three times that silence) no 100 ms prefers another upstream
The same, frozen upstream yes as for an unclean drop prefers another upstream
No byte within upstream_liveness_timeout_ms (1 s after the monitor lost it) no 100 ms prefers another upstream; a live one is marked offline
The monitor marked it offline and it sent nothing for 5 s while owed no 100 ms prefers another upstream
Frozen contract violation yes as for an unclean drop prefers another upstream
A live resume jumped past the cursor while another upstream covers it no none avoids it for 30 s
A live resume broke prev_block continuity while another upstream covers the block no none avoids it for 30 s, or until no upstream continues the block
The request could not be written to a new link no 100 ms avoids it for 2 s
Moved by an operator no 100 ms avoids it until the next link is up, and returns there if no other upstream can take the session
Moved off an archive without a block log no none see fetch_block and block logs
A message above max_message_bytes no the client is closed with 1011 block <K> exceeds max_message_bytes

Each pause gets ±20 % jitter. In a pool listener, an unclean drop within 5 s after the router forwarded a request variant it does not know closes the client with 1003 unsupported request instead, with no strike: the client's frame, not the upstream, is the likely cause.

Contract violations are checked on frozen results only: a result without ids where the archive has ids or a head-only keep-alive (idless), a block above (gap) or below (rewind) the expected one, a requested field missing where the archive has that log (missing_field), a result while the client had no credit (credit), the wrong result version or a status result (variant), and a break at a seam (discontinuity). fleet_router_upstream_contract_violations_total{kind} counts them.

A failed dial makes the session avoid that upstream for 2 s, and marks a live upstream offline unless it answered 503. A session never marks a frozen upstream offline. A stitched session that cannot dial any upstream after three tries holds (see above); a pool session closes the client with 1011.

fleet_router_failovers_total{reason} counts failovers by reason; see Observability.

fetch_block and block logs

An archive may run without a block log. It then sends no block in its results, although it has the traces and deltas. The router handles that without counting it against the archive:

The flag covers the whole archive and is kept per listener: an archive with a partial block log is flagged after its first missing block, until it reconnects or reports a new status. A live node that is stale or lags is not used while a fresh archive without a block log covers the cursor; the session moves once the node is fresh again.

Readiness and coverage

/ready means the listener can serve the chain tip: a stitched listener needs an online live upstream (any online upstream when it has no live server). With live servers configured, an archive outage never makes it fail, so one dead archive cannot pull the router out of a load balancer. /ready?scope=history also requires the archived history to be complete and online. GET /coverage returns, per listener, the fleet tip, the canonical ABI and each log's coverage with the upstreams that serve each segment. See Observability for both, and for alerts on the fleet gauges.

Security

Do not expose fleet-router directly to the public internet.

Deploy on a trusted or internal network, and/or behind a reverse proxy (nginx, Caddy, Envoy) that adds access control. Terminate TLS in fleet-router or at the reverse proxy for encrypted client connections; TLS alone does not add access control.

The optional dashboard is off by default. When you enable it, it is served on metrics_port with no login for reading, and it shows client IP addresses. Its control actions need a bearer token. Keep metrics_address on a private interface (for example 127.0.0.1 behind an SSH tunnel or an authenticating reverse proxy).

Maintenance without dropping clients. With the control API enabled, POST /api/v1/listeners/<listener>/upstreams/<endpoint>/drain (or the Drain button on the dashboard) stops new sessions on that upstream and moves its current sessions to other upstreams. They resume at the next block and their clients stay connected. Restart or resync the node, then enable it again.

To report a vulnerability, see SECURITY.md.

Run as a service

Run fleet-router under your platform's service manager so it starts at boot and restarts if it exits. Before installing it, check the config with fleet-router config test <path>: a service that fails validation on start just restarts in a loop.

Linux (systemd)

The repository ships two units:

  1. Install the binary and create an unprivileged user:

    bash
    sudo install -m 0755 fleet-router /usr/local/bin/fleet-router   # or: cargo install fleet-router, then copy ~/.cargo/bin/fleet-router
    sudo useradd --system --no-create-home --shell /usr/sbin/nologin fleet
    
  2. Write and check the config:

    bash
    sudo mkdir -p /etc/fleet-router
    sudo fleet-router config init /etc/fleet-router/config.json
    sudoedit /etc/fleet-router/config.json        # list your SHiP nodes
    fleet-router config test /etc/fleet-router/config.json
    
  3. Install the unit, then enable and start it:

    bash
    sudo cp deploy/fleet-router.service /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now fleet-router
    

    With the template unit, name each config after its network (/etc/fleet-router/eos.json, /etc/fleet-router/wax.json, each with its own listen_port and metrics_port) and enable one instance per network:

    bash
    sudo cp deploy/fleet-router@.service /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now fleet-router@eos fleet-router@wax
    
  4. Check that it is running and follow the logs:

    bash
    systemctl status fleet-router
    journalctl -u fleet-router -f
    

enable starts the service at boot, and Restart=on-failure restarts it if it crashes. After you edit the config, run config test again and then sudo systemctl restart fleet-router. A restart drains open sessions (see Graceful shutdown); clients reconnect to the new process.

Both units also:

To change a setting without editing the shipped file, use a drop-in: sudo systemctl edit fleet-router (for example, Environment=RUST_LOG=debug).

Docker

bash
docker run -d --name fleet-router --restart unless-stopped \
  -p 17000:17000 \
  -v "$PWD/config.json:/etc/fleet-router.json:ro" \
  ghcr.io/eosrio/fleet-router run --config /etc/fleet-router.json

--restart unless-stopped starts the container again when the Docker daemon starts (at boot, if the daemon is enabled) and after a crash, until you docker stop it. The image runs as a non-root user. Its HEALTHCHECK runs fleet-router check, which dials 127.0.0.1:17000. For one container per network, see deploy/docker-compose.multi-network.yml, which sets the same restart policy; start it with docker compose up -d.

macOS (launchd)

Save this as /Library/LaunchDaemons/io.eosrio.fleet-router.plist. Adjust the paths and UserName:

xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>io.eosrio.fleet-router</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/fleet-router</string>
    <string>run</string>
    <string>--config</string>
    <string>/usr/local/etc/fleet-router/config.json</string>
  </array>
  <key>UserName</key>
  <string>_fleet</string>
  <key>RunAtLoad</key>
  <true/>
  <key>KeepAlive</key>
  <true/>
  <key>SoftResourceLimits</key>
  <dict>
    <key>NumberOfFiles</key>
    <integer>65536</integer>
  </dict>
  <key>StandardOutPath</key>
  <string>/usr/local/var/log/fleet-router.log</string>
  <key>StandardErrorPath</key>
  <string>/usr/local/var/log/fleet-router.log</string>
</dict>
</plist>
bash
sudo launchctl bootstrap system /Library/LaunchDaemons/io.eosrio.fleet-router.plist
sudo launchctl kickstart -k system/io.eosrio.fleet-router   # restart after a config change

RunAtLoad starts it at boot and KeepAlive restarts it when it exits. The user named in UserName must exist and be able to write the log file. Stop and remove it with sudo launchctl bootout system/io.eosrio.fleet-router.

Windows

fleet-router is a console program. It does not implement the Windows service control protocol, so registering the .exe directly with sc.exe create fails to start (error 1053). Run it under a service wrapper such as NSSM:

powershell
nssm install fleet-router C:\fleet-router\fleet-router.exe run --config C:\fleet-router\config.json
nssm set fleet-router AppStdout C:\fleet-router\fleet-router.log
nssm set fleet-router AppStderr C:\fleet-router\fleet-router.log
nssm start fleet-router

The service starts automatically at boot, and NSSM restarts the process if it exits. On stop, NSSM sends Ctrl+C first, which triggers the router's graceful shutdown.

Graceful shutdown

SIGINT and SIGTERM (sent by systemctl stop) trigger graceful shutdown: the router stops accepting new connections, drains in-flight ones for up to shutdown_grace_ms, and sends WebSocket close frames (code 1001) to clients. A chain API port stops accepting too; a request in progress finishes and its connection closes, within the same grace period. Keep systemd's TimeoutStopSec comfortably larger than shutdown_grace_ms.

Corrupted SHiP logs

A damaged trace_history.log or chain_state_history.log makes nodeos drop the SHiP socket with no WebSocket close frame when a client reaches that block (zlib error: iostream error). Indexers then reconnect and request the same block forever. fleet-router treats that drop as a mid-stream failure:

This bound is always on. Quarantine is off until block_failure_quarantine is set, at the top level or on a listener.

Tuning

See the configuration reference for every field and its default.