Skip to content

Service Discovery

BackendPool maintains a thread-safe list of "host:port" addresses and can refresh that list on a background thread. All clones of a BackendPool share the same underlying RwLock<Vec<String>>, so the live backend list is always consistent across the process.

Discovery sources

SourceConstructorRefresh
Fixed listBackendPool::static(...)Never
Environment variablesBackendPool::env_prefix(...)On poll interval
FileBackendPool::file(...)On poll interval
DNS A-recordBackendPool::dns(...)On poll interval
DNS SRV recordBackendPool::dns_srv(...)On poll interval
Consul health APIBackendPool::consul(...)On poll interval
Docker container labelsBackendPool::docker(...)On poll interval
etcd v3 watchBackendPool::etcd(...)Live — dedicated watch stream, not polling

Static list

use rust_web_server::service_discovery::BackendPool;
let pool = BackendPool::r#static(vec![
"10.0.0.1:8080".into(),
"10.0.0.2:8080".into(),
]);
// Immediately available — no .start() required
println!("{:?}", pool.backends());

Environment variable prefix

Scans PREFIX_0, PREFIX_1, PREFIX_2, … in order, stopping at the first variable that is absent:

// Reads MY_SVC_BACKEND_0, MY_SVC_BACKEND_1, etc.
let pool = BackendPool::env_prefix("MY_SVC_BACKEND")
.poll_interval_secs(60);
pool.start();

Set the environment variables before starting:

Terminal window
export MY_SVC_BACKEND_0=api-1.internal:8080
export MY_SVC_BACKEND_1=api-2.internal:8080

File-based discovery

One host:port per line. Blank lines and lines starting with # are ignored:

backends.txt
api-1.internal:8080
api-2.internal:8080
# api-3.internal:8080 <- temporarily disabled
let pool = BackendPool::file("backends.txt")
.poll_interval_secs(30);
pool.start();

DNS A-record lookup

Resolves the hostname to all IP addresses returned by the OS (via ToSocketAddrs). Each IP becomes a "ip:port" entry:

// Resolves api.service.consul every 30 s
let pool = BackendPool::dns("api.service.consul", 8080)
.poll_interval_secs(30);
pool.start();

DNS SRV lookup (weighted DNS)

Resolves an SRV record (e.g. a Kubernetes headless Service’s per-port record: _http._tcp.my-svc.default.svc.cluster.local) via a hand-rolled DNS query — no third-party DNS crate. Only the lowest-priority tier of answers is kept (RFC 2782: clients try that tier first); within it, each target:port is repeated weight.clamp(1, 20) times so a plain round-robin consumer still favors higher-weight targets proportionally — that repetition is “weighted DNS” here, since a flat Vec<String> backend list has no other way to express weight:

let pool = BackendPool::dns_srv("_http._tcp.my-svc.default.svc.cluster.local")
.poll_interval_secs(15);
pool.start();

Consul

Queries a Consul agent’s /v1/health/service/:name endpoint, which already applies health-check filtering server-side (passing=true) — only instances passing every check are returned. Service.Address is preferred; if a service registered without one, falls back to Node.Address:

// addr is host:port of the Consul agent, e.g. a local agent or a sidecar.
let pool = BackendPool::consul("127.0.0.1:8500", "api")
.poll_interval_secs(10);
pool.start();

Docker container labels

Queries the local Docker Engine API over its Unix domain socket (/var/run/docker.sock by default) for running containers carrying a given label, using the label’s value as the backend address directly:

// A container run with: docker run -l rws.backend=10.0.0.5:8080 ...
let pool = BackendPool::docker("rws.backend")
.poll_interval_secs(10);
pool.start();
// Non-default socket path:
let pool = BackendPool::docker_with_socket("rws.backend", "/custom/docker.sock");

This is deliberately not “guess the address from published ports” — a container can be reachable via its bridge-network IP, a published host port, an overlay-network IP, or a sidecar, and there’s no single correct answer without knowing the deployment topology. Requiring an explicit label value sidesteps that ambiguity entirely. Unix-only; on other platforms discover() logs a warning and returns an empty list.

etcd v3 watch

Unlike every other source, EtcdWatch is not driven by the poll loop at all once .start() is called:

let pool = BackendPool::etcd(vec!["127.0.0.1:2379".into()], "/services/api/");
pool.start();

.start() still performs one synchronous refresh() first — a one-shot /v3/kv/range listing of the key prefix, so pool.backends() is populated immediately and the pool is fully usable even if you never call .start() at all. But instead of the generic sleep-and-poll thread, .start() spawns a dedicated thread holding a long-lived connection to etcd’s gRPC-gateway /v3/watch endpoint, applying PUT/DELETE events to the backend list incrementally as they arrive — no polling interval after that. On disconnect it reconnects (with a short backoff) and re-lists from scratch before resuming the watch, so it never silently stops tracking changes.

Each key’s value is used as the backend address directly (etcdctl put /services/api/1 10.0.0.5:8080, one key per instance under the shared prefix). Plain HTTP only — etcd deployments that require TLS need a plaintext sidecar/proxy in front for now.

Starting background refresh

.start() performs an immediate synchronous refresh, then spawns a daemon thread that calls .refresh() every poll_interval_secs seconds. For Static sources .start() is a no-op.

pool.start(); // call once at application startup

Reading the current backend list

let backends: Vec<String> = pool.backends();

backends() takes a read lock and returns a snapshot. It never blocks for longer than the lock acquisition.

Manual updates

For control planes that push backend lists externally:

pool.update(vec![
"new-backend-1:8080".into(),
"new-backend-2:8080".into(),
]);

Integrating with ReverseProxy

use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::service_discovery::BackendPool;
use rust_web_server::proxy::ReverseProxy;
let pool = BackendPool::dns("api.internal", 8080)
.poll_interval_secs(30);
pool.start();
// Snapshot the backend list at startup; wire the pool into a handler
// or refresh the proxy on each request for dynamic resolution.
let backends = pool.backends();
let app = App::new()
.wrap(ReverseProxy::new(backends));

Poll interval

pool.poll_interval_secs(30) // default: 30 s; File, Dns, DnsSrv, Consul, and Docker sources only

The poll interval is set before .start(). Changing it after .start() has no effect on the running background thread. EtcdWatch ignores it entirely — after the initial refresh(), updates arrive from the live watch stream, not on a timer.