Troubleshooting Reference
Troubleshooting Guide
Common failure modes and step-by-step playbooks. Each section starts with symptoms, then diagnosis commands, then fixes.
Quick sanity checks
Run these first for any issue:
# Is the daemon running?
systemctl status gslbd # systemd
docker compose ps # Docker
kubectl get pods -l app=gslbd # Kubernetes
# Is it healthy?
curl -s http://localhost:8080/api/v1/health | jq .
# Recent log lines (structured JSON)
journalctl -u gslbd -n 50 --output=json | jq -r '[.SYSLOG_TIMESTAMP, .MESSAGE] | @tsv'DNS not responding
Symptoms: dig times out; no DNS
response at all.
Diagnosis:
# Is the listener up?
dig @127.0.0.1 -p 5353 A example.com +time=2
# Is port 5353 open?
ss -ulnp | grep 5353 # UDP
ss -tlnp | grep 5353 # TCP
# Any bind errors in logs?
journalctl -u gslbd | grep -i "listen\|bind\|address in use"Fixes:
| Cause | Fix |
|---|---|
| Port already in use | Another process holds 5353. ss -lnp sport = :5353 to
find it. Change dns.port in config or stop the conflicting
process. |
| Port 53 permission denied | Port 53 requires CAP_NET_BIND_SERVICE. Uncomment
AmbientCapabilities=CAP_NET_BIND_SERVICE in the systemd
unit, or use port 5353 and redirect with iptables:
iptables -t nat -A PREROUTING -p udp --dport 53 -j REDIRECT --to-port 5353. |
| Database path wrong | If database.path does not exist or is unreadable, the
DNS server starts but returns SERVFAIL for all DB-backed services.
Check: ls -la /var/lib/gslbd/ and ensure the
gslbd user owns it. |
| Config parse failure | The daemon exits immediately. Check exit code:
systemctl show gslbd --property=ExecMainStatus. Revalidate:
gslbd -config /etc/gslb/config.yaml -validate. |
DNS returns NXDOMAIN or SERVFAIL for a known service
Symptoms: dig gets a response but the
domain does not resolve to any IP.
Diagnosis:
# Enable query logging in config: dns.queryLog: true, then:
journalctl -u gslbd | grep "dns query"
# Check the service exists in DB
curl -s http://localhost:8080/api/v1/services | jq '.[] | select(.domain == "app.example.com.")'
# Check pool members exist and are healthy
curl -s http://localhost:8080/api/v1/pools/<pool-id>/status | jq .Common causes:
- Domain not terminated with
.— Nexus GSLB matches on the fully-qualified name including the trailing dot. Ensureservices.domainisapp.example.com.notapp.example.com. - No healthy members — If all pool members are unhealthy (health checks failing), the service returns no records. Check pool status above; see All members unhealthy.
- Service not linked to pool — A service exists but
has no pool assigned. Update via API:
PUT /api/v1/services/<id>withpoolID. - Algorithm mismatch — If the service uses
geo-ipbut no GeoIP database is configured, resolution falls back to no candidates. Check logs forgeo-ip watcher could not start.
All members unhealthy
Symptoms: Pool status shows 0 healthy members; DNS returns no records.
Diagnosis:
# Pool health overview
curl -s http://localhost:8080/api/v1/pools | jq '.[] | {id, name}'
curl -s http://localhost:8080/api/v1/pools/<pool-id>/status
# Metrics (if enabled)
curl -s http://localhost:9090/metrics | grep gslbd_health_endpoints_healthy
# Health checker logs
journalctl -u gslbd | grep -E "health|probe|unhealthy"Fixes:
| Cause | Fix |
|---|---|
| Health check type wrong | tcp check on an HTTP-only port returns unhealthy.
Change health check type to http or
https. |
| Timeout too short | Under load, probes time out before the backend responds. Increase
healthCheck.timeout (default 5 s). |
| TLS mismatch with HTTPS check | If the backend uses a self-signed cert and
insecureSkipVerify: false, probes fail. Either fix the cert
or set insecureSkipVerify: true (logs a warning). |
| Backend port wrong | healthCheck.port does not match the actual service
port. Verify with
curl -v http://<backend>:<port>/. |
| Health checks disabled | healthCheck.enabled: false treats all endpoints as
healthy. If probes were just enabled, wait one full
interval for the first probe to complete. |
| Firewall blocking probes | The gslbd host cannot reach backend ports. Test:
nc -zv <backend-ip> <port> from the gslbd
host. |
Failover is slow
Symptoms: After a backend goes down, DNS continues returning it for longer than expected.
Failover has three independent layers — diagnose each separately:
Layer 1 — Detection (gslbd still returning the failed IP)
# Check the pool's current health status
curl -s http://localhost:8080/api/v1/pools/<pool-id>/status | jq .
# Is the detection latency in line with your checkInterval?
curl -s http://localhost:9090/metrics | grep failover_detection
# gslbd_health_failover_detection_seconds_bucket{direction="down",...}
# What is the configured check interval and timeout?
curl -s http://localhost:8080/api/v1/pools/<pool-id>/health-check | jq '{intervalMs, timeoutMs}'| Cause | Fix |
|---|---|
checkInterval too long |
Lower health.checkInterval (e.g. 5s or
2s). Tradeoff: higher probe volume. |
timeout too long relative to interval |
timeout should be ≤ 50% of checkInterval.
A timed-out probe blocks the next tick. |
scoreWindow delaying confirmation |
With scoreWindow: N, the state only flips when the
rolling success rate crosses a threshold — up to N probe intervals.
Lower scoreWindow or set to 0 for immediate flip. |
| Health check type too slow | script and webhook probes have
subprocess/network overhead. tcp is fastest. |
Layer 2 — Peer convergence (other cluster nodes still returning the failed IP)
# Is NATS connected?
curl -s http://localhost:9090/metrics | grep gslbd_state_nats_connected
# What is the NATS transit lag?
curl -s http://localhost:9090/metrics | grep merge_lag_msPeer convergence should be under 200 ms with the event-driven publisher. If it is slow:
| Cause | Fix |
|---|---|
| NATS not connected | Fix NATS connectivity (see NATS state sync not working). Until reconnected, peers use stale state. |
High gslbd_state_merge_lag_ms p95 |
High network latency between nodes or NATS servers. Check cross-region RTT; confirm NATS super-cluster gateway links are up. |
Layer 3 — Client cache (clients still hitting the failed IP)
This is a DNS protocol property, not a gslbd bug. Clients cache responses for the TTL configured on the service.
# What TTL are you serving?
dig +noall +answer @<gslbd-host> -p <port> <service-domain> A | awk '{print $2}'| Cause | Fix |
|---|---|
| TTL too high | Lower per-service ttl (API:
PUT /api/v1/services/<id> with
"ttl": 10). Tradeoff: more DNS queries. |
| Clients ignoring TTL | Some HTTP clients and OS caches ignore short TTLs. Nothing gslbd can do — this is client-side behaviour. |
| Load balancer / CDN in front | Intermediate load balancers may cache DNS results independently. Check their DNS TTL settings. |
See Performance — Failover latency for a full breakdown and recommended configurations by SLA target.
High DNS latency
Symptoms:
gslbd_dns_query_duration_seconds p99 > 10 ms; queries
are slow under load.
Diagnosis:
# Latency breakdown from metrics
curl -s http://localhost:9090/metrics | grep gslbd_dns_query_duration
# Database slow queries (SQLite WAL mode should be fast, but check)
# Add -v to see timing:
time curl -s http://localhost:8080/api/v1/services > /dev/null
# Is the DB index present? (query the rqlite datastore over its HTTP API)
curl -sG http://localhost:4001/db/query --data-urlencode \
"q=SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='services'"
# Should include: idx_services_domainFixes:
| Cause | Fix |
|---|---|
| Missing domain index | Run via the rqlite CLI:
rqlite -H localhost -p 4001 "CREATE UNIQUE INDEX IF NOT EXISTS idx_services_domain ON services(lower(rtrim(domain,'.')));"
The daemon creates this on startup — if missing, the datastore may
predate this version. |
| No datastore reachable | If rqlite.httpAddr is unset or rqlited is
down, gslbd cannot resolve DB-backed services. Check
systemctl status rqlited and
curl -s localhost:4001/status. |
| GeoIP DB too large | MaxMind City databases are 60–70 MB. Cold lookups (first query after
reload) may spike. Confirm with the
gslbd_dns_query_duration_seconds{algorithm="geo-ip"}
bucket. Switch to Country DB if City granularity is not needed. |
| DNS query timeout context | The DNS server uses a 200 ms timeout per SQLite query. If the DB is under heavy write load (backup running), reads may queue. Schedule backups off-peak. |
NATS state sync not working
Symptoms: Nodes do not reflect each other's health
state; gslbd_state_nats_connected == 0.
Diagnosis:
# Check NATS connection status
curl -s http://localhost:9090/metrics | grep gslbd_state_nats
# Logs
journalctl -u gslbd | grep -i "nats\|jetstream\|state"
# Can the node reach NATS?
nc -zv <nats-host> 4222Fixes:
| Cause | Fix |
|---|---|
| NATS server unreachable | Firewall or wrong state.nats.servers list. Check
telnet <nats-host> 4222. |
| JetStream not enabled | Connect to NATS CLI: nats server info. If JetStream is
disabled, jetstream: enabled: true in the NATS server
config and restart. |
| TLS cert mismatch | If state.nats.tls.caFile is set, the CA must match the
NATS server's cert. Verify:
openssl s_client -connect <nats-host>:4222 -CAfile <caFile>. |
| Auth credentials wrong | state.nats.auth.user/password or NKey seed mismatch.
Check NATS server logs for auth errors. |
| Cluster ID mismatch | All nodes sharing state must have the same cluster.id.
Nodes with different cluster IDs publish to different JetStream buckets
and will not see each other. |
| JetStream stream missing | On first connect, gslbd creates the stream
automatically. If it fails (quota, permission), check NATS account
limits. |
GitOps sync failures
Symptoms: Config changes pushed to the repo are not
applied; gslbd_gitops_apply_total{result="error"}
increases.
Diagnosis:
curl -s http://localhost:9090/metrics | grep gslbd_gitops
journalctl -u gslbd | grep -i "gitops\|reconcile\|signature\|verify"Fixes:
| Cause | Fix |
|---|---|
| Signature verification failed | requireSignature: true but the commit is not signed, or
the signing key is not in allowedSigners. Verify:
git log --show-signature HEAD. Add the signer's fingerprint
to config. |
| Repo URL unreachable | The daemon cannot clone/fetch. Test:
git ls-remote <repoURL> as the gslbd
user. Check SSH keys or token auth. |
| Config file parse error | The new config has a syntax error. Fix the YAML and push a new commit. The daemon reverts to last-good config on delete events. |
pathPrefix wrong |
The config file is not at
<pathPrefix>/config.yaml in the repo. Check
gitops.pathPrefix. |
| Clock drift | Signature timestamps outside tolerance. Sync clocks:
chronyc tracking. |
DNSSEC validation failures
Symptoms: Resolvers return SERVFAIL for
signed zones; DNSSEC-aware clients cannot validate responses.
Diagnosis:
# Test DNSSEC validation end-to-end
dig @127.0.0.1 -p 5353 +dnssec A app.example.com.
# Check key expiry
curl -s http://localhost:9090/metrics | grep gslbd_dnssec_key_days_remaining
# Verify DS record is published at registrar
dig DS example.com. @8.8.8.8Fixes:
| Cause | Fix |
|---|---|
| KSK/ZSK not loaded | Check logs for DNSSEC disabled (key load error). Verify
PEM file paths and permissions (gslbd user must be able to
read them). |
| DS record not published | After generating keys, export the DS record and submit to the
registrar:
gslbctl dnssec ds --zone example.com. --ksk-file /etc/gslb/ksk.pem. |
| Key expired | gslbd_dnssec_key_days_remaining < 0. Generate new
ZSK, publish alongside old ZSK (double-signing window), then remove old.
See docs/Security.md. |
| Clock skew | RRSIG inception is now − 1h to tolerate drift. If the
resolver's clock is more than 1 h ahead, signatures appear invalid. Sync
clocks. |
| Response truncation causing TC=1 loop | Large signed responses (> 1232 bytes) set TC=1 to force TCP retry. If the client does not retry over TCP, it will see no answer. Ensure TCP port 5353 is open and reachable. |
API returns 401 Unauthorized
Symptoms: curl, browser, or API client
calls return HTTP 401.
Causes and fixes:
| Cause | Fix |
|---|---|
| Not logged in / session expired | Log in again: POST /api/v1/auth/login or navigate to
/ui/login in the browser. |
| Sending an old or revoked session token | Obtain a new token by logging in again. |
| Sending the system API key but it doesn't match the server | Check GSLB_API_KEY in the server's
/etc/gslb/env. |
GSLB_API_KEY unset on server (dev mode) but client
sends a Bearer |
In dev mode the server accepts all requests; a 401 suggests the
server does have GSLB_API_KEY set. |
| Cookie not sent by browser | Ensure the browser is hitting the same origin as the API (served via the same Caddy/nginx reverse proxy). SameSite=Strict cookies are not sent cross-origin. |
Diagnosis:
# Test the system API key directly
curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $GSLB_API_KEY" \
http://localhost:8080/api/v1/pools
# Test a session token
curl -s -H "Authorization: Bearer <token>" http://localhost:8080/api/v1/auth/me
# Check whether the daemon has GSLB_API_KEY set (dev mode check)
sudo cat /etc/gslb/env | grep GSLB_API_KEYLogin returns "invalid credentials"
Symptoms: POST /api/v1/auth/login
returns {"error": "invalid credentials"}.
Diagnosis and fixes:
- Wrong email or password — double-check the email address (case-insensitive) and password.
- Password was set via direct DB manipulation with Python
bcrypt— ensure the$2b$hash prefix is correct. Python'sbcryptlibrary produces$2b$hashes, which Go'sgolang.org/x/crypto/bcryptaccepts. - The user exists in the database with the wrong
tenant_id— check:
# Query the rqlite datastore over its HTTP API
curl -sG http://localhost:4001/db/query --data-urlencode \
"q=SELECT email, tenant_id, password_hash IS NOT NULL FROM users"
curl -sG http://localhost:4001/db/query --data-urlencode \
"q=SELECT id, name FROM tenants"The tenant_id in users must match an
id in tenants. On upgraded deployments, run
the latest binary once — startup automatically migrates any legacy UUID
tenant id to the literal string "default".
Login returns
{"mfaRequired": true}
Symptoms: Login succeeds for password but TOTP code is required.
The account has TOTP enrolled. Include the 6-digit code from your authenticator app:
curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email": "you@example.com", "password": "...", "totpCode": "123456"}'Lost TOTP device (admin recovery): A
tenant_admin user or the system API key can clear another
user's TOTP secret by updating the user record. This requires a working
login — if both the admin's password and TOTP device are unavailable,
use the break-glass procedure below.
Break-glass: resetting a user password or clearing TOTP via the DB
Use only when the API is inaccessible or all admin accounts are locked out.
# Install Python bcrypt if needed
pip3 install bcrypt
python3 <<'EOF'
import sqlite3, bcrypt
db = '/var/lib/gslbd/gslbd.db'
email = 'admin@example.com'
new_password = 'temporary-reset-password'
pw_hash = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12)).decode()
conn = sqlite3.connect(db)
conn.execute('UPDATE users SET password_hash=?, totp_secret=NULL, updated_at=strftime("%s","now") WHERE email=?',
(pw_hash, email))
conn.commit()
rows = conn.execute('SELECT email, role, tenant_id FROM users WHERE email=?', (email,)).fetchall()
print('Updated:', rows)
conn.close()
EOFRestart gslbd after direct DB writes to ensure the
running process reflects current state.
Daemon refuses to start: "GSLB_SECRET_KEY must be set"
Symptom: The daemon exits at startup with an error
about GSLB_SECRET_KEY.
At least one user in the database has a TOTP secret stored, and
GSLB_SECRET_KEY is not set (or is incorrect). Generate a
new key and add it to the environment file:
openssl rand -hex 32
# Add to /etc/gslb/env:
# GSLB_SECRET_KEY=<output>If the key was lost: The existing TOTP secrets are unrecoverable without the original key. Set a new key, then clear all TOTP secrets from the database:
python3 -c "
import sqlite3
c = sqlite3.connect('/var/lib/gslbd/gslbd.db')
c.execute('UPDATE users SET totp_secret=NULL')
c.commit()
print('Cleared TOTP secrets for', c.execute('SELECT COUNT(*) FROM users WHERE totp_secret IS NOT NULL').fetchone()[0], 'users')
"Users will need to re-enroll their TOTP devices.
Setup endpoint returns 404 after first user created
Symptom: POST /api/v1/auth/setup
returns 404.
This is expected — the setup endpoint is permanently disabled once
any user exists. Use the API with admin credentials or
--create-admin on a fresh database.
"No users exist" but setup endpoint still returns 404
Check auth.setupEndpoint in config.yaml —
it may have been set to false. Set it to true
and restart.
Rate limiting / license errors
Symptoms: Logs show
no valid license; enforcing unlicensed RPS limit; DNS
clients get REFUSED or timeouts under load.
Diagnosis:
journalctl -u gslbd | grep -i "license\|rps\|rate"
curl -s http://localhost:8080/api/v1/health | jq .licenseFixes:
| Cause | Fix |
|---|---|
| No license configured | Set GSLB_LICENSE_KEY and
GSLB_LICENSE_SECRET environment variables (or
license.key/license.secret in config). |
| License expired | Renew the license. Until renewed, the daemon enforces the unlicensed RPS cap. |
| Wrong credentials | Key/secret mismatch. The daemon logs the license tier and expiry on startup if credentials are valid. |
Database errors / corruption
Symptoms: Logs show database disabled;
API returns 503; DNS stops resolving DB-backed services.
Diagnosis:
# Check DB file integrity
sqlite3 /var/lib/gslbd/gslbd.db "PRAGMA integrity_check;"
# Is the DB owned by gslbd?
ls -la /var/lib/gslbd/
# Check available disk space
df -h /var/lib/gslbdFixes:
| Cause | Fix |
|---|---|
| Disk full | Free space. SQLite WAL writes fail silently if the disk is full.
du -sh /var/lib/gslbd/* to find large WAL/SHM files;
PRAGMA wal_checkpoint(TRUNCATE); to compact. |
| Permission denied | chown -R gslbd:gslbd /var/lib/gslbd. |
| Corruption | Restore from backup:
cp /var/backups/gslbd/gslbd-<date>.db /var/lib/gslbd/gslbd.db && chown gslbd: /var/lib/gslbd/gslbd.db.
Restart the daemon. |
| WAL file left behind after crash | If the process was killed hard, WAL may be unapplied. SQLite
recovers this automatically on next open; if
integrity_check still reports errors, restore from
backup. |
Manual backup (safe while daemon is running):
sqlite3 /var/lib/gslbd/gslbd.db "VACUUM INTO '/var/backups/gslbd/gslbd-$(date +%Y%m%d-%H%M).db';"Config validation errors at startup
Symptoms: Daemon exits immediately with a validation error message.
Run the validator before restarting:
gslbd -config /etc/gslb/config.yaml -validateCommon messages and fixes:
| Message | Fix |
|---|---|
dns.port: must be between 1 and 65535 |
Set dns.port to a valid port, e.g. 5353. |
state.nats.servers: must not be empty when state sync is enabled |
Add at least one NATS server URL, e.g.
nats://localhost:4222. |
loadBalancer.algorithm: invalid value "roundrobin" |
Use one of: round-robin,
weighted-round-robin, geo-ip,
map-file. |
dnssec.zone: must be a valid FQDN ending in '.' |
Set dnssec.zone: "example.com." (trailing dot
required). |
backup.intervalSeconds: must be > 0 |
Set a positive interval, e.g.
backup.intervalSeconds: 3600. |
gslbctl cannot connect to API
Symptoms: gslbctl commands return
connection errors.
Diagnosis:
# Is the API port listening?
curl -v http://localhost:8080/api/v1/health
# gslbctl uses GSLB_API_URL and GSLB_API_KEY env vars
export GSLB_API_URL=http://localhost:8080
export GSLB_API_KEY=your-key
gslbctl pools listFix: The API server only starts when
database.path is configured. If api.enabled is
true but no database path is set, the daemon logs
API server disabled: database path not configured or failed to open
and does not bind the port.
Collecting a diagnostics bundle
When filing a bug report:
#!/usr/bin/env bash
OUT="gslbd-diag-$(date +%Y%m%d-%H%M)"
mkdir "$OUT"
# Logs (last 500 lines)
journalctl -u gslbd -n 500 --output=json > "$OUT/journal.json" 2>/dev/null
# Version
gslbd -version > "$OUT/version.txt" 2>&1
# Config (redact secrets first)
cp /etc/gslb/config.yaml "$OUT/config.yaml"
sed -i 's/key:.*/key: REDACTED/g; s/secret:.*/secret: REDACTED/g; s/password:.*/password: REDACTED/g' "$OUT/config.yaml"
# Metrics snapshot
curl -s http://localhost:9090/metrics > "$OUT/metrics.txt" 2>/dev/null
# Health API
curl -s http://localhost:8080/api/v1/health > "$OUT/health.json" 2>/dev/null
# DB integrity (no data, just check)
sqlite3 /var/lib/gslbd/gslbd.db "PRAGMA integrity_check;" > "$OUT/db-integrity.txt" 2>/dev/null
tar czf "${OUT}.tar.gz" "$OUT" && rm -rf "$OUT"
echo "Bundle: ${OUT}.tar.gz"