Filter Chains
Filter chains let you compose multiple routing steps per DNS query. Instead of locking a service to a single algorithm, you declare an ordered list of filters that are applied in sequence — narrowing the candidate set at each step until a terminal filter selects one endpoint.
License: Filter chains require the paid tier or above. Services with a non-empty
filterChainarray return HTTP 403 on free and unlicensed installations. Contact licensing@gslb.nexus to upgrade.
Filter Categories
Filters fall into two categories.
Narrowing filters
A narrowing filter reorders or reduces the candidate set. If applying the filter would produce zero candidates, it is skipped (pass-through) and the previous candidate set is kept unchanged. The chain then continues with the next filter.
| Filter | Behaviour |
|---|---|
geo-ip |
Keeps only candidates whose region field matches the client's ISO country code. Falls back to SortByPreference ordering if no members have a matching region or the client country is unknown. |
asn |
Reorders candidates by ASN proximity using SortByPreference. All candidates are kept; matching-ASN endpoints move to the front. |
map-file |
Reorders candidates by CIDR map preference using SortByPreference. All candidates are kept; matched endpoints move to the front. |
sticky |
Pins the client to a single member (LDNS persistence). Narrows to one candidate chosen by rendezvous hashing of the client subnet. See LDNS Persistence. |
signal-weight |
Biases (never removes) candidates by a per-region carbon/cost value. Requires pro tier. See Signal-Weight Routing and CarbonAwareRouting.md. |
Selecting filters (terminal)
A selecting filter picks exactly one candidate and terminates the chain. No further filters run after it.
| Filter | Behaviour |
|---|---|
round-robin |
Stateful round-robin across the current candidate set. Counter is per-service. |
weighted-round-robin |
Weighted random selection from the current candidate set using member weight values. |
failover |
Returns the highest-priority healthy candidate (lowest priority integer value). Within a priority tier, higher health score wins. |
latency |
Returns a candidate by weighted random selection, weighted by inverse RTT measured from this cluster node. |
rum (alias rum-latency) |
Returns the candidate with the lowest real-user latency measured from the client's subnet by the browser beacon. Falls back to latency behaviour when no measurement covers the client. Requires pro tier. See RUMRouting.md. |
Execution Model
Given a chain ["geo-ip", "asn", "weighted-round-robin"]:
- Start with all healthy candidates for the pool, sorted by health score.
- geo-ip: narrow to candidates in the client's region. If none match, keep all.
- asn: reorder survivors by ASN proximity. Keep all survivors.
- weighted-round-robin: weighted-random-select one candidate from survivors. Return it.
If the chain exhausts without reaching a terminal filter (e.g. a chain of only narrowing filters), the first candidate in the current set is returned.
Member region Field
The geo-ip filter matches against each member's region field. This must be set explicitly — it is not derived from the member's IP address.
Region values must match the ISO 3166-1 alpha-2 country codes returned by the GeoIP database (e.g. "US", "DE", "JP"). Comparison is case-sensitive. Members with region unset default to "unknown" and will not match any client country.
POST /api/v1/pools/{id}/members
{
"ipAddress": "10.0.1.5",
"port": 80,
"region": "US"
}Configuration
REST API
Set filterChain on create or update. When filterChain is present, algorithm is ignored.
POST /api/v1/services
{
"name": "web-prod",
"domain": "www.example.com.",
"poolId": "pool-uuid",
"filterChain": ["geo-ip", "weighted-round-robin"],
"ttl": 30
}
PUT /api/v1/services/{id}
{
"name": "web-prod",
"domain": "www.example.com.",
"poolId": "pool-uuid",
"filterChain": ["geo-ip", "asn", "failover"],
"ttl": 30
}
To revert to a single algorithm, send algorithm with an empty or absent filterChain:
PUT /api/v1/services/{id}
{
"name": "web-prod",
"domain": "www.example.com.",
"poolId": "pool-uuid",
"algorithm": "round-robin"
}
The filterChain field is returned in all service responses when set:
{
"id": "svc-uuid",
"name": "web-prod",
"domain": "www.example.com.",
"poolId": "pool-uuid",
"algorithm": "",
"filterChain": ["geo-ip", "weighted-round-robin"],
"ttl": 30
}Web UI
When creating or editing a service, click Use filter chain (top-right of the routing section) to switch from the single-algorithm dropdown to the filter chain builder:
- Add filters from the palette below the chain.
- Reorder with the ↑ / ↓ buttons.
- Remove individual steps with ✕.
- A preview line shows the composed chain:
Geo IP → Weighted Round Robin.
Click Use single algorithm to revert to a single algorithm value.
TUI (gslbctl)
When creating or editing a service, the form includes a Filter Chain (optional) field below the Algorithm selector. Enter filter names as a comma-separated list:
Filter Chain (optional): geo-ip,asn,weighted-round-robin
Leave the field blank to use the value from the Algorithm selector instead. The service list view displays the chain in the ALGORITHM column as geo-ip→asn→weighted-round-robin.
Terraform
Use the filter_chain attribute on nexus_service. When set, algorithm is ignored by the API.
resource "nexus_service" "web" {
name = "web-prod"
domain = "www.example.com."
pool_id = nexus_pool.web.id
filter_chain = ["geo-ip", "weighted-round-robin"]
ttl = 30
}
To use a single algorithm, omit filter_chain and set algorithm:
resource "nexus_service" "web" {
name = "web-prod"
domain = "www.example.com."
pool_id = nexus_pool.web.id
algorithm = "round-robin"
ttl = 30
}
Both algorithm and filter_chain are Optional+Computed. If neither is specified, the service defaults to round-robin.
LDNS Persistence (Sticky)
The sticky step gives a client consistent answers over time — the same member on every query — the way F5 BIG-IP DNS (GTM) per-LDNS persistence does. Use it for stateful backends: sessions pinned to a site, licensing servers, legacy apps that don't share state across members.
How it works. sticky narrows the candidate set to a single member chosen by rendezvous (highest-random-weight) hashing of the client's resolver subnet (/24 for IPv4, /64 for IPv6; ECS subnet when present). Place it before the terminal selector:
["geo-ip", "sticky", "weighted-round-robin"]Here geo-ip narrows to the client's region, sticky pins to one member within that region, and the selector trivially returns it.
Why rendezvous hashing, not a session table. The pin is a pure function of (client subnet, healthy candidate set):
- Deterministic and node-local-free — every node computes the same answer, so a client hitting a different anycast node still gets the same member. No shared affinity table, no cross-node sync.
- Survives restarts — there is no state to lose.
- Minimal disruption — when a member is added or removed, only the clients pinned to that member move; everyone else keeps their pin. (A modulo or round-robin scheme would reshuffle everyone.)
Health always wins over stickiness. Candidates are health-filtered before the chain runs, so a member that fails its health check simply drops out of the set and its clients re-pin to the next-highest-weight healthy member immediately. There is no "sticky to a dead target" window.
No affinity window in v1. Stickiness lasts exactly as long as the pinned member stays healthy — which is the behaviour stateful backends want — so there is no windowSeconds to tune. The tradeoff: the pin follows the current healthy set, so scaling a pool up/down re-pins the clients whose hash winner changed (see minimal-disruption above), rather than holding an expiring lease.
Migrating from F5 BIG-IP DNS. A GTM pool with "Global Availability" or "Ratio" load balancing plus per-LDNS persistence maps to a filter chain of your selecting step (e.g. weighted-round-robin for Ratio) with sticky in front of it. F5's persistence TTL has no direct equivalent — Nexus persists for the lifetime of the member's health rather than a fixed timer.
Query tracer. The Query Tracer shows the sticky step as a narrowed action pinning to one member (or skipped when ≤1 candidate remains).
Signal-Weight Routing (Carbon/Cost)
Pro tier.
signal-weight biases candidate ordering by a per-region scalar signal — carbon intensity, cost, or any value where lower is preferred — without ever excluding a healthy member. It's a reorder among already-health-filtered candidates, same as asn/map-file.
Members map to a signal zone via their existing region field (the same field geo-ip uses). Configure the zone table server-side, not per-service:
signals:
static:
us-east: 450 # e.g. gCO2/kWh
eu-west: 120
weight: 0.5 # 0 disables the bias; 1 lets it dominate. Default 0.5.v1 ships static, operator-supplied values only — no live feed integration (ElectricityMaps, WattTime) yet. systemctl reload gslbd (SIGHUP) picks up config changes without a restart.
Blend, not override. Candidates are re-scored as score*(1-weight) + bias*weight, where bias is a min-max normalization of the signal value among the candidates present in this query (lowest value → highest bias). A candidate whose region has no configured value keeps its original score untouched — it's never penalized for missing data, though it can still be fairly outranked by a candidate whose blended score legitimately comes out higher.
Query tracer. Shows a reordered action with the zone values used and the configured weight, or skipped when no candidate's region has a configured value.
Observability. gslbd_signal_zone_value{zone} gauge reports the live configured table; gslbd_signal_queries_steered_total{service} counts queries where the step actually changed the top-ranked candidate. Both are visible in the WebUI under Signal Weight (Infrastructure); gslbctl signals list for the CLI.
See CarbonAwareRouting.md for the full walkthrough and the ESG/marketing framing.
Scriptable Steps (Starlark)
Enterprise tier. Requires the scripts:write permission to author.
A chain step may be an inline Starlark script for custom routing logic that the built-in filters don't cover — route by a customer ID embedded in the qname, business-hours weighting, and similar long-tail rules.
A script step is a chain entry of the form script:<body> (the body is a Starlark program). It runs on the DNS hot path against the current candidate set and narrows/reorders it.
Inputs
The script is given these globals:
| Global | Type | Fields |
|---|---|---|
candidates |
list of dict | ip, region, weight, priority, health_score, rtt_ms |
client |
dict | ip, subnet, asn, geo |
qname |
string | the queried name |
qtype |
string | A, AAAA, … |
now |
int | current unix time |
The script must set the global result to a list of candidate IP strings — the surviving candidates, in the order it wants them. IPs not in the original candidate set are ignored.
Sandbox & safety
- No I/O: no file, network, or module (
load) access. - Bounded: a step budget plus a ~2 ms wall-clock deadline (scripts run per query).
- Fail-open: any compile error, timeout, or a result that filters everything out leaves the candidate set unchanged, increments
gslbd_dns_script_step_error_total{service}, and logs (rate-limited). A broken script never blackholes a service. - Validated on write: the body is compile-checked when the service is saved, so a syntax error is rejected by the API — not discovered at query time.
- The Query Tracer shows each script step's before/after candidates and run duration.
Examples (cookbook)
Business-hours weighting — only keep the primary region during 09:00–17:00 UTC, otherwise keep all:
hour = (now // 3600) % 24
if 9 <= hour < 17:
result = [c["ip"] for c in candidates if c["region"] == "primary"] or [c["ip"] for c in candidates]
else:
result = [c["ip"] for c in candidates]Route by customer ID in the qname — cust-<n>.svc.example.com → pin even/odd customers to different regions:
label = qname.split(".")[0] # e.g. "cust-42"
region = "eu" if int(label.split("-")[1]) % 2 == 0 else "us"
result = [c["ip"] for c in candidates if c["region"] == region] or [c["ip"] for c in candidates]Drop unhealthy, then prefer lowest RTT (narrowing before a later selecting step):
healthy = [c for c in candidates if c["health_score"] >= 0.8]
healthy = sorted(healthy, key=lambda c: c["rtt_ms"])
result = [c["ip"] for c in healthy]Put a selecting step (e.g. weighted-round-robin) after a narrowing script, or let the script return a single IP to act as the terminal selector.
Common Patterns
Geo-first with weighted fallback — prefer regional endpoints, select by weight among survivors:
["geo-ip", "weighted-round-robin"]ISP-aware routing — prefer same-region, then same-ASN, then highest-priority failover:
["geo-ip", "asn", "failover"]Map-file with latency tiebreak — steer by CIDR rule, then pick the fastest survivor:
["map-file", "latency"]Pure failover — no geographic step; always prefer highest-priority healthy endpoint:
["failover"]Backward Compatibility
Existing services that use the algorithm field continue to work without change. Filter chains are opt-in: if filterChain is absent or empty, the single algorithm value is used. The two modes are mutually exclusive at query time — filterChain takes precedence when set.
Interaction with Other Features
- Composite health policy (
minHealthy): runs before filter chain dispatch. If the pool is below threshold, no filter chain step executes — the query returns no answer. - Health scoring: candidates are pre-sorted by health score before the first filter runs. Unhealthy members (score = 0) are excluded from the initial candidate set.
- EDNS0 Client Subnet: the
geo-ipfilter uses the client IP extracted from the ECS option when present, falling back to the resolver's source IP. - Multi-tenancy:
filterChainis stored per-service and scoped to the owning tenant. It is included in NATS state-sync snapshots and replicated to peer nodes. - DNS cache: the domain→service cache stores the full service record including
filterChain. Cache is invalidated on every service update.
Limitations
- A chain of only narrowing filters (no terminal selector) returns
candidates[0]— typically the highest-health-score endpoint. Add a terminal filter to control selection explicitly. - The
geo-ipfilter's pass-through applies per-query: if a client's region has no matching members, all members are eligible for the next step. This is intentional — it prevents blackholing traffic when regional capacity is unavailable. - Filter names must exactly match the values in the table above. Unknown filter names are rejected by the API with a validation error.
- The
round-robinfilter's counter is per-service and is reset when the service is restarted or the member set changes.