mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 19:01:42 +00:00
225bf65a8e
Merges `feat/ebs-export-tunables` (83 commits) into dev. The branch could not be rebase-merged because it carries five merges from dev, so it lands as a squash. ## What is in it - **EBS export tunables** and the nbdkit/viperblock export path they configure. - **Teardown reapers and backoff** for S3 buckets, load balancers, gateways, spot requests, certificates, endpoints, elastic IPs and network interfaces, so a dependent that keeps failing is backed off rather than retried hot. - **Retry and tolerance paths**: ECS IAM service init, ECR signing keys deleted between the list and the read, an unreadable signing key at awsgw startup, and an ECS instance-profile policy attach converging on a stale read. - **Key pair validation reads the material by name** instead of listing the account, closing the `InvalidKeyPair.NotFound` race that a single-replica LIST could produce. - **Concurrency and fan-out**: concurrent metadata document fetches, bounded metadata reads for describes, and the node-discovery timeout removed from every fan-out call. - **OVN correctness against the configured NB cluster**: the flows barrier, nexthop MAC binding, and stopping a compute node from running the package's standalone database. - **nats-js-watchdog can re-arm itself.** The escalation check ran above the probe loop while the only line that cleared the counter sat below it, so a latched watchdog could never observe a recovery. It now probes first and decides second, and says so once per latch rather than once per tick. - **CI**: 19 commits of nightly and build-pipeline work. ## Verification `make preflight` green locally. Build and Test, Integration Tests, Race Detection, and Lint and Security all green on `c2da0385`. ## Known red, not introduced here Two nightly cells fail on this branch and are pre-existing: - **cell-10** — a partial OVN IPsec enable leaves `NB_Global ipsec=true` asserted cluster-wide while two of three chassis failed their local reconcile, black-holing cross-chassis guest traffic for roughly 5.5 minutes while every health signal stays green. - **cell-19** — a total layer-3 egress blackhole below a correctly and promptly programmed NAT/EIP datapath, aggravated by a natuplink probe whose retry budget outlives the test's own wait, so it can never emit its FAIL verdict. Both are filed separately and should be weighed before a release is cut.
191 lines
7.8 KiB
Bash
Executable File
191 lines
7.8 KiB
Bash
Executable File
#!/bin/sh
|
|
# JetStream liveness watchdog for spinifex-nats.
|
|
#
|
|
# NATS can stop accepting JetStream writes at runtime while the process stays
|
|
# alive and keeps serving reads. That is not a process failure, so
|
|
# Restart=on-failure never fires and KV-backed services (awsgw IAM bucket init,
|
|
# etc.) stall until NATS is restarted by hand.
|
|
#
|
|
# Two distinct latches produce that symptom, and they need different probes:
|
|
#
|
|
# 1. Server-wide. handleOutOfSpace calls ShutdownJetStream and sets an
|
|
# internal disabled flag that only a restart clears. healthz reports it.
|
|
# 2. Per message block. filestore records a write error on the block and
|
|
# returns it on every later write; nothing clears it short of a restart.
|
|
# healthz does NOT report it, because the server-wide state is untouched —
|
|
# only an actual write observes it.
|
|
#
|
|
# So a health check alone misses case 2 entirely. Both probes run here, and a
|
|
# sustained failure of either restarts the service. The multi-sample window
|
|
# avoids restarting during transient startup/quorum 503s.
|
|
#
|
|
# Three more guards sit around that decision, because "restart when the
|
|
# probes fail" is not safe on its own:
|
|
#
|
|
# - Age gate: spinifex-nats.service is Type=simple, so it reports active the
|
|
# instant the process forks, long before JetStream finishes replaying its
|
|
# state. Acting before a recovery budget has elapsed restarts a server
|
|
# that is still coming up, not one that has failed.
|
|
# - Cooldown: a restart that did not fix things must not be retried by the
|
|
# very next run, seconds later.
|
|
# - Escalation: a restart that has not fixed things after several attempts
|
|
# is not going to fix it on the next one either, and an unrunnable spx
|
|
# must never be read as proof of a JetStream failure at all.
|
|
|
|
set -eu
|
|
|
|
HEALTH_URL="http://127.0.0.1:8222/healthz?js-enabled-only=true"
|
|
SERVICE="spinifex-nats.service"
|
|
SPX="${SPX_BIN:-/usr/local/bin/spx}"
|
|
SAMPLES="${SAMPLES:-3}" # consecutive failing samples required before acting
|
|
SAMPLE_GAP="${SAMPLE_GAP:-2}" # seconds between samples
|
|
PROBE_TIMEOUT="${PROBE_TIMEOUT:-5}" # seconds for one canary round-trip
|
|
|
|
# Recovery budget: on casuarina NATS needed 3m49s between "Started" and
|
|
# JetStream actually accepting writes, and the bead recorded ~6min elsewhere.
|
|
# 600s clears both with margin.
|
|
NATS_MIN_AGE="${NATS_MIN_AGE:-600}"
|
|
|
|
# Minimum time between restarts this watchdog performs, independent of the
|
|
# age gate: closes the case where a restart did not fix things and the very
|
|
# next run pulls the trigger again before the new process could recover.
|
|
RESTART_COOLDOWN="${RESTART_COOLDOWN:-600}"
|
|
|
|
# A restart that has not fixed things after this many attempts is not going
|
|
# to fix it on the next one either, and looping costs more than stopping.
|
|
RESTART_ESCALATE_AT="${RESTART_ESCALATE_AT:-3}"
|
|
|
|
STATE_DIR="${STATE_DIR:-/run/spinifex}"
|
|
COOLDOWN_STAMP="$STATE_DIR/nats-js-watchdog.last-restart"
|
|
RESTART_COUNT_FILE="$STATE_DIR/nats-js-watchdog.restart-count"
|
|
# Marks that the escalation message has already been printed for the current
|
|
# latch, so a node that stays broken says so once instead of once per tick.
|
|
ESCALATED_STAMP="$STATE_DIR/nats-js-watchdog.escalated"
|
|
|
|
# Monotonic clock source, so a wall-clock step during boot (NTP correcting an
|
|
# unset RTC) cannot manufacture a false age or cooldown. Overridable so tests
|
|
# can stub a clock without faking /proc.
|
|
UPTIME_FILE="${UPTIME_FILE:-/proc/uptime}"
|
|
|
|
uptime_seconds() {
|
|
awk '{printf "%d", $1}' "$UPTIME_FILE"
|
|
}
|
|
|
|
# nats_age_seconds prints how long $SERVICE has been running. It reads
|
|
# ExecMainStartTimestampMonotonic rather than is-active: that property goes
|
|
# true the instant Type=simple forks, long before JetStream has replayed its
|
|
# state, so it cannot express the recovery window this gate exists to
|
|
# protect. A unit systemd has never started prints 0, which also covers a
|
|
# stopped/activating unit the way the old is-active guard did.
|
|
nats_age_seconds() {
|
|
start_usec=$(systemctl show --property=ExecMainStartTimestampMonotonic --value "$SERVICE" 2>/dev/null) || start_usec=""
|
|
case "$start_usec" in
|
|
''|*[!0-9]*) echo 0; return ;;
|
|
esac
|
|
if [ "$start_usec" -eq 0 ]; then
|
|
echo 0
|
|
return
|
|
fi
|
|
echo $(( $(uptime_seconds) - start_usec / 1000000 ))
|
|
}
|
|
|
|
age=$(nats_age_seconds)
|
|
if [ "$age" -lt "$NATS_MIN_AGE" ]; then
|
|
exit 0
|
|
fi
|
|
|
|
if [ -f "$COOLDOWN_STAMP" ]; then
|
|
last=$(cat "$COOLDOWN_STAMP" 2>/dev/null) || last=""
|
|
case "$last" in ''|*[!0-9]*) last=0 ;; esac
|
|
if [ $(( $(uptime_seconds) - last )) -lt "$RESTART_COOLDOWN" ]; then
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
restart_count=$(cat "$RESTART_COUNT_FILE" 2>/dev/null) || restart_count=""
|
|
case "$restart_count" in ''|*[!0-9]*) restart_count=0 ;; esac
|
|
|
|
# The escalation count is read here but acted on only after the probes, below.
|
|
# Checking it up front would return before the one line that clears it, so a
|
|
# latched watchdog could never observe recovery and never re-arm itself.
|
|
|
|
# curl is the probe transport; if it is unavailable, do nothing rather than
|
|
# false-restart a healthy server.
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
echo "nats-js-watchdog: curl not found, skipping JS health probe" >&2
|
|
exit 0
|
|
fi
|
|
|
|
js_healthy() {
|
|
curl -fsS -o /dev/null --max-time 5 "$HEALTH_URL" 2>/dev/null
|
|
}
|
|
|
|
# Round-trips a canary key through the cluster-state bucket. Exit 0 means the
|
|
# write succeeded and exit 1 means JetStream refused it; anything else
|
|
# (spx's own exit 2, a missing binary, or one that cannot run at all) means
|
|
# the probe observed nothing about JetStream and must never be read as
|
|
# evidence of a failure.
|
|
js_writable() {
|
|
if [ ! -x "$SPX" ]; then
|
|
return 2
|
|
fi
|
|
"$SPX" admin node js-probe --timeout "${PROBE_TIMEOUT}s" >/dev/null 2>&1
|
|
rc=$?
|
|
case "$rc" in
|
|
0) return 0 ;;
|
|
1) return 1 ;;
|
|
*) return 2 ;;
|
|
esac
|
|
}
|
|
|
|
failure=""
|
|
i=1
|
|
while [ "$i" -le "$SAMPLES" ]; do
|
|
if js_healthy; then
|
|
if js_writable; then
|
|
wrc=0
|
|
else
|
|
wrc=$?
|
|
fi
|
|
if [ "$wrc" -eq 0 ]; then
|
|
# Recovery: drop the history so a later failure gets the full
|
|
# restart budget again, and re-arm the escalation message.
|
|
rm -f "$RESTART_COUNT_FILE" "$ESCALATED_STAMP"
|
|
exit 0
|
|
fi
|
|
if [ "$wrc" -eq 2 ]; then
|
|
echo "nats-js-watchdog: js-probe inconclusive (config, connect, bucket-open, or spx itself failed); not evidence of a JetStream failure, taking no action" >&2
|
|
exit 0
|
|
fi
|
|
failure="JetStream accepts no writes (canary round-trip failed) while $HEALTH_URL reports healthy"
|
|
else
|
|
failure="JetStream unhealthy at $HEALTH_URL"
|
|
fi
|
|
if [ "$i" -lt "$SAMPLES" ]; then
|
|
sleep "$SAMPLE_GAP"
|
|
fi
|
|
i=$((i + 1))
|
|
done
|
|
|
|
# The failure is real and current, so the restart history now means what it
|
|
# says. Above the threshold another restart will not help, so stop acting —
|
|
# but keep probing on later runs, because that is what notices a recovery.
|
|
if [ "$restart_count" -ge "$RESTART_ESCALATE_AT" ]; then
|
|
if [ ! -f "$ESCALATED_STAMP" ]; then
|
|
mkdir -p "$STATE_DIR"
|
|
: > "$ESCALATED_STAMP"
|
|
echo "nats-js-watchdog: already restarted $SERVICE $restart_count time(s) without recovery; giving up rather than looping (clear $RESTART_COUNT_FILE to re-arm)" >&2
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
echo "nats-js-watchdog: $failure on $SAMPLES consecutive probes; restarting $SERVICE" >&2
|
|
mkdir -p "$STATE_DIR"
|
|
uptime_seconds > "$COOLDOWN_STAMP"
|
|
restart_count=$((restart_count + 1))
|
|
echo "$restart_count" > "$RESTART_COUNT_FILE"
|
|
if [ "$restart_count" -ge "$RESTART_ESCALATE_AT" ]; then
|
|
echo "nats-js-watchdog: restart $restart_count of $RESTART_ESCALATE_AT before escalation; if $SERVICE is still failing after this, the watchdog will stop acting until $RESTART_COUNT_FILE is cleared" >&2
|
|
fi
|
|
systemctl try-restart "$SERVICE"
|