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 filterChain array 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"]:

  1. Start with all healthy candidates for the pool, sorted by health score.
  2. geo-ip: narrow to candidates in the client's region. If none match, keep all.
  3. asn: reorder survivors by ASN proximity. Keep all survivors.
  4. 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 qnamecust-<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-ip filter uses the client IP extracted from the ECS option when present, falling back to the resolver's source IP.
  • Multi-tenancy: filterChain is 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-ip filter'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-robin filter's counter is per-service and is reset when the service is restarted or the member set changes.

Was this article helpful?
© 2026