OpenSearch — admin recovery runbook
The admin dashboard's OpenSearch card renders a link to this runbook
whenever the probe surfaces a structured cause code. Each section below
maps a cause code to the diagnostic path. Use the dev stack
(mydev_opensearch at 172.18.16.46:9200) for all reproductions; the
production cluster is operator-only.
Cause codes
| Code | Card headline |
|---|---|
startup_failed |
OpenSearch — startup fallback (configured but never connected) |
dns_failure |
OpenSearch — DNS lookup failed |
auth_failure |
OpenSearch — authentication rejected |
tls_failure |
OpenSearch — TLS / certificate error |
network |
OpenSearch — network unreachable |
timeout |
OpenSearch — probe timed out |
cluster_red |
Cluster status: red |
version_mismatch |
OpenSearch — version mismatch |
unknown |
OpenSearch — unreachable (verbatim error in the small-print field) |
startup_failed
The API booted with search.backend = opensearch and a URL set, but the
boot-time HealthCheck against cfg.Search.URL failed. The process is
running with osClient = nil and every search read/write is going
through the Postgres pg_trgm fallback. Code search is degraded; issue
search returns the legacy results path.
The probe wired in internal/opensearch/admin_health.go
attempts a late-bind reconnect on every /admin/health call. If the
cluster comes back the next probe clears StartupFailed and the card
flips to healthy without an API restart.
Diagnostic checklist:
- Confirm intent vs reality:
cat $CONFIG_DIR/app.toml | grep -A2 '\[search\]' curl -m 3 -sv $OPENSEARCH_URL/_cluster/health - If the URL is reachable from the operator host but not from the API
container, the issue is network policy / DNS scoping inside the
container — not OpenSearch itself. Resolve from inside the container:
docker exec mydev_vetrix wget -qO- "$OPENSEARCH_URL/_cluster/health" - If the URL is unreachable everywhere: bring the cluster up. The API will self-heal on the next 30 s probe — no rolling restart.
dns_failure
Hostname in cfg.Search.URL cannot be resolved. Common causes:
- Compose service name (
mydev_opensearch) used outside the compose network — switch to the static IP or add the host toextra_hosts. - Recently-renamed cluster — the URL still points at the old name.
auth_failure
OpenSearch returned 401/403. The probe wraps the bare _cluster/health
call so credentials live in the URL or Authorization header
constructed in the SDK. Rotate the password via admin settings; never
log the URL with embedded credentials (the probe explicitly avoids
this).
tls_failure
Certificate not trusted by the API container. If the cluster runs
HTTPS with a self-signed CA, mount the CA into the API container's
trust store; do not flip InsecureSkipVerify permanently — surface
a hardening follow-up ticket and patch via the trust store.
network
connection refused, no route to host, connection reset, or
similar. Cluster process is up but something between API and cluster
is not. Check Docker network policy, firewall, port exposure.
timeout
Probe context expired before the cluster answered. Probe budget is
admin.healthProbeBudget = 150 ms inside CollectHealth (overrides
the 1 s standalone probe budget). A wedged cluster, full GC pause, or
disk-full red state will all surface as timeout. Cross-check
/_cluster/health?timeout=2s from the operator host before assuming
the cluster is dead — a single timeout window can be transient.
cluster_red
Cluster answered but reports status: red. Walk through:
- Disk pressure:
_cat/allocation. Free up data nodes if any are atflood_stage(default 95 %). - Shard allocation:
_cluster/allocation/explain. Common causes are missing replicas after a node crash; bring the node back or reduce the replica count temporarily. - Index corruption: rare, but
_cat/indices?vshowsredindexes alongside the cluster. Recover via snapshot restore.
version_mismatch
Server reports an OpenSearch version the SDK does not support. Pin the
cluster to a supported major (>= 2.x) and retry the probe.
Reachable cluster, no code
If the card is healthy (no code field), the probe round-trip
succeeded and cluster_status is green or yellow. No action required.
Push alerting
The structured-code admin card and this runbook cover the
operator-watching-the-dashboard path. For on-call to be paged without
watching the dashboard, the API exposes two Prometheus-text-exposition
gauges on the /metrics scrape endpoint:
| Series | Type | Meaning |
|---|---|---|
opensearch_admin_health_status |
gauge | 1 when the most recent admin probe round-trip succeeded, 0 when it failed. A red-but-reachable cluster reports 1 here. |
opensearch_admin_health_consecutive_failures |
gauge | Run-length of consecutive failed probes since the last success. Resets to 0 on the next reachable probe. |
Both series are labelled instance="<hostname>" so a multi-replica
deployment can scope the alert per-API-pod. The override
metrics.SetOpenSearchAdminInstance is available for deployments where
the Kubernetes pod name is the meaningful identifier rather than the
container's internal hostname.
The consecutive_failures series is rendered as a gauge — not a
counter — because it MUST decrement on success. Counter semantics
break rate() and increase() if the value ever drops; the
"consecutive" framing IS a gauge: the current run-length. Alert rules
threshold on >= N directly without rate().
Probe cadence
The admin dashboard polls /api/v1/admin/health every 30 s (fixed
cadence). Each poll runs the OpenSearch probe and updates both
gauges. Two consecutive failed polls equals 60 s of unreachability —
the threshold below.
Alert rule
Append to your Prometheus / alertmanager rule files:
groups:
- name: vetrix-opensearch-admin
rules:
- alert: OpenSearchAdminProbeDisconnected
expr: opensearch_admin_health_consecutive_failures >= 2
for: 60s
labels:
severity: page
service: vetrix-opensearch
annotations:
summary: "OpenSearch admin probe has failed {{ $value }} consecutive times on {{ $labels.instance }}"
description: |
The Vetrix admin probe to OpenSearch has failed for two or
more consecutive 30s polls on instance {{ $labels.instance }}.
Search reads are degraded (Postgres pg_trgm fallback). See
the operator runbook for the structured cause-code matrix.
runbook_url: "/runbook-docs/search/opensearch-recovery.md"
The for: 60s clause guards against a single 30 s probe blip caused by
a transient network hiccup; the alert fires only when both probes
inside the 60 s window failed (run-length stayed >= 2 for the full
interval).
Verifying the metric
Reproduce the disconnect locally with the dev stack:
docker compose stop mydev_opensearch
sleep 60
curl -s http://localhost/metrics | grep opensearch_admin_health
docker compose start mydev_opensearch
Expected: after ~60 s the _consecutive_failures series is >= 2 and
_status is 0. After OpenSearch comes back the next probe flips
_status to 1 and resets _consecutive_failures to 0.
If the deployment has no Prometheus scraper
The metric series are still emitted on /metrics regardless of
whether anyone scrapes them — alertmanager wiring is operator-side. If
your deployment ships without a Prometheus pipeline, point any
HTTP-scrape-capable monitor (Datadog, Vector, plain curl cron) at the
endpoint and threshold on opensearch_admin_health_consecutive_failures.
The series name is stable; the value semantics match across scrapers.
Cross-references
- Health probe code:
internal/admin/health_opensearch.go - Adapter (late-bind retry):
internal/opensearch/admin_health.go - Frontend card:
web/src/components/admin/OpenSearchHealthCard.tsx - Boot-time wiring:
cmd/server/main.go(search forosIntent) - Push-alert metrics:
internal/metrics/opensearch_health.go - Probe-side emission:
internal/admin/health_opensearch.go(search forRecordOpenSearchAdminHealth)