fix: reduce health-check probe pressure to stop readiness-flapping downtime
Three changes targeting the root cause of production up/down alerting: 1. Skip BruteForceProtection for /health and /health/live paths (removes unnecessary Repo.get_by(IpBlock) DB query from every k8s probe, matching the existing /socket/agent exemption). 2. Relax k8s readiness probe: periodSeconds 5→10, timeoutSeconds 2→5, failureThreshold 2→3, successThreshold 3→2 so transient DB/Redis blips don't cascade into full service unavailability. 3. Use the persistent Towerops.Redix connection for health checks instead of opening a brand-new TCP connection (handshake→AUTH→ PING→close) on every probe. Towerops.Redix.Fake gains a set_ping_response/2 toggle for testing failure paths.
This commit is contained in:
parent
26ac82653e
commit
b5e3c1f977
8 changed files with 135 additions and 89 deletions
|
|
@ -1,3 +1,21 @@
|
||||||
|
2026-06-08
|
||||||
|
fix: reduce health-check probe pressure to stop readiness-flapping downtime
|
||||||
|
Three changes to eliminate the root cause of production up/down alerting:
|
||||||
|
1. BruteForceProtection plug now skips /health and /health/live paths (like
|
||||||
|
/socket/agent already does), removing an unnecessary Repo.get_by(IpBlock)
|
||||||
|
query from every k8s readiness probe.
|
||||||
|
2. k8s readiness probe relaxed: periodSeconds 5→10, timeoutSeconds 2→5,
|
||||||
|
failureThreshold 2→3 to tolerate brief DB/Redis blips without marking the
|
||||||
|
pod unready; successThreshold 3→2 to recover faster after a blip clears.
|
||||||
|
3. RedisHealthCheck.check_health/2 now sends PING on the persistent
|
||||||
|
Towerops.Redix connection (started at boot) instead of opening a brand-new
|
||||||
|
TCP connection + AUTH + PING + close on every probe. Towerops.Redix.Fake
|
||||||
|
gained a set_ping_response/2 toggle so tests can drive the failure path
|
||||||
|
without dialing unreachable hosts.
|
||||||
|
Files: lib/towerops/redis_health_check.ex, lib/towerops/redix/fake.ex,
|
||||||
|
lib/towerops_web/plugs/brute_force_protection.ex, k8s/deployment.yaml,
|
||||||
|
test/towerops_web/controllers/health_controller_test.exs
|
||||||
|
|
||||||
2026-05-20
|
2026-05-20
|
||||||
fix(oban): use Oban Pro DynamicLifeline in prod to stop unique_violation crash loop
|
fix(oban): use Oban Pro DynamicLifeline in prod to stop unique_violation crash loop
|
||||||
CheckExecutorWorker's `unique` constraint covers available/scheduled/retryable
|
CheckExecutorWorker's `unique` constraint covers available/scheduled/retryable
|
||||||
|
|
|
||||||
|
|
@ -293,10 +293,10 @@ spec:
|
||||||
path: /health
|
path: /health
|
||||||
port: 4000
|
port: 4000
|
||||||
initialDelaySeconds: 5
|
initialDelaySeconds: 5
|
||||||
periodSeconds: 5
|
periodSeconds: 10 # Check every 10s instead of 5s (halves probe pressure)
|
||||||
timeoutSeconds: 2
|
timeoutSeconds: 5 # Give DB+Redis checks enough time to respond
|
||||||
successThreshold: 3 # Require 3 consecutive successes (15s) before marking ready
|
successThreshold: 2 # 2 consecutive successes (20s) before marking ready
|
||||||
failureThreshold: 2 # Allow 1 failure before marking not ready
|
failureThreshold: 3 # Allow 2 consecutive failures before marking not ready
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: data
|
- name: data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|
|
||||||
|
|
@ -63,54 +63,28 @@ defmodule Towerops.RedisHealthCheck do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Checks Redis health by attempting a PING command with a timeout.
|
Checks Redis health by sending a PING on the existing persistent connection.
|
||||||
|
|
||||||
|
Uses the named `Towerops.Redix` connection (started at boot by
|
||||||
|
`Towerops.Application`) instead of opening a brand-new TCP connection
|
||||||
|
on every probe. This avoids the TCP handshake + AUTH per probe and
|
||||||
|
cuts per-check work roughly in half.
|
||||||
|
|
||||||
## Parameters
|
## Parameters
|
||||||
- `redis_config` - Keyword list with Redis connection config (host, port, password)
|
- `redis_config` - Ignored; accepted for backward compat with callers.
|
||||||
- `timeout_ms` - Maximum time to wait for response in milliseconds
|
- `timeout_ms` - Maximum time to wait for the PONG response.
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
- `:ok` if Redis responds to PONG
|
- `:ok` if Redis responds to PONG
|
||||||
- `{:error, reason}` if connection fails or times out
|
- `{:error, reason}` if the command fails or times out
|
||||||
"""
|
"""
|
||||||
@spec check_health(keyword(), pos_integer()) :: :ok | {:error, term()}
|
@spec check_health(keyword(), pos_integer()) :: :ok | {:error, term()}
|
||||||
def check_health(redis_config, timeout_ms \\ 2_000) do
|
def check_health(_redis_config, timeout_ms \\ 2_000) do
|
||||||
host = Keyword.get(redis_config, :host, "localhost")
|
|
||||||
port = Keyword.get(redis_config, :port, 6379)
|
|
||||||
password = Keyword.get(redis_config, :password)
|
|
||||||
|
|
||||||
# Build connection options
|
|
||||||
conn_opts = [
|
|
||||||
host: host,
|
|
||||||
port: port,
|
|
||||||
timeout: timeout_ms,
|
|
||||||
socket_opts: [keepalive: true]
|
|
||||||
]
|
|
||||||
|
|
||||||
conn_opts =
|
|
||||||
if password && password != "" do
|
|
||||||
Keyword.put(conn_opts, :password, password)
|
|
||||||
else
|
|
||||||
conn_opts
|
|
||||||
end
|
|
||||||
|
|
||||||
redix = redix_module()
|
redix = redix_module()
|
||||||
|
|
||||||
# Start a temporary connection for health check
|
case redix.command(Towerops.Redix, ["PING"], timeout: timeout_ms) do
|
||||||
case redix.start_link(conn_opts) do
|
{:ok, "PONG"} -> :ok
|
||||||
{:ok, conn} ->
|
{:error, reason} -> {:error, reason}
|
||||||
result =
|
|
||||||
case redix.command(conn, ["PING"], timeout: timeout_ms) do
|
|
||||||
{:ok, "PONG"} -> :ok
|
|
||||||
{:error, reason} -> {:error, reason}
|
|
||||||
end
|
|
||||||
|
|
||||||
redix.stop(conn)
|
|
||||||
result
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
Logger.debug("Redis health check connection failed: #{inspect(reason)}")
|
|
||||||
{:error, reason}
|
|
||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
error ->
|
error ->
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,19 @@ defmodule Towerops.Redix.Fake do
|
||||||
GenServer.call(conn, :reset)
|
GenServer.call(conn, :reset)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Overrides the PING response for testing failure paths.
|
||||||
|
|
||||||
|
Pass `:error` to make PING return `{:error, %Redix.ConnectionError{...}}`.
|
||||||
|
Pass `:ok` (or anything else) to restore the default `{:ok, "PONG"}`.
|
||||||
|
"""
|
||||||
|
def set_ping_response(conn \\ __MODULE__, response) do
|
||||||
|
GenServer.call(conn, {:set_ping_response, response})
|
||||||
|
end
|
||||||
|
|
||||||
# ── GenServer ────────────────────────────────────────────────────────
|
# ── GenServer ────────────────────────────────────────────────────────
|
||||||
@impl true
|
@impl true
|
||||||
def init(_), do: {:ok, %{sets: %{}}}
|
def init(_), do: {:ok, %{sets: %{}, ping_override: nil}}
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:command, cmd}, _from, state) do
|
def handle_call({:command, cmd}, _from, state) do
|
||||||
|
|
@ -62,11 +72,26 @@ defmodule Towerops.Redix.Fake do
|
||||||
{:reply, reply, state}
|
{:reply, reply, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call(:reset, _from, _state), do: {:reply, :ok, %{sets: %{}}}
|
def handle_call(:reset, _from, _state), do: {:reply, :ok, %{sets: %{}, ping_override: nil}}
|
||||||
|
|
||||||
|
def handle_call({:set_ping_response, response}, _from, state) do
|
||||||
|
{:reply, :ok, %{state | ping_override: response}}
|
||||||
|
end
|
||||||
|
|
||||||
# ── Command dispatch ────────────────────────────────────────────────
|
# ── Command dispatch ────────────────────────────────────────────────
|
||||||
defp run(["PING"], state), do: {{:ok, "PONG"}, state}
|
defp run(["PING"], state) do
|
||||||
defp run(["PING" | _], state), do: {{:ok, "PONG"}, state}
|
case state.ping_override do
|
||||||
|
:error -> {{:error, %Redix.ConnectionError{reason: :econnrefused}}, state}
|
||||||
|
_ -> {{:ok, "PONG"}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run(["PING" | _], state) do
|
||||||
|
case state.ping_override do
|
||||||
|
:error -> {{:error, %Redix.ConnectionError{reason: :econnrefused}}, state}
|
||||||
|
_ -> {{:ok, "PONG"}, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp run(["SADD", key, value | rest], state) do
|
defp run(["SADD", key, value | rest], state) do
|
||||||
set = Map.get(state.sets, key, MapSet.new())
|
set = Map.get(state.sets, key, MapSet.new())
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,12 @@ defmodule ToweropsWeb.Plugs.BruteForceProtection do
|
||||||
String.starts_with?(conn.request_path, "/socket/agent") ->
|
String.starts_with?(conn.request_path, "/socket/agent") ->
|
||||||
conn
|
conn
|
||||||
|
|
||||||
|
# Skip for Kubernetes health probes — these run every 5s and don't
|
||||||
|
# need brute-force checking; a DB query here adds latency to readiness
|
||||||
|
# checks and contributes to probe failures during DB contention.
|
||||||
|
String.starts_with?(conn.request_path, "/health") ->
|
||||||
|
conn
|
||||||
|
|
||||||
# Check if IP is whitelisted
|
# Check if IP is whitelisted
|
||||||
BruteForce.check_whitelist(ip_address) ->
|
BruteForce.check_whitelist(ip_address) ->
|
||||||
conn
|
conn
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
2026-06-08 — Availability improvements
|
||||||
|
* Reduced momentary unavailability during brief database or Redis slowdowns
|
||||||
|
* Health monitoring overhead cut significantly, improving overall responsiveness
|
||||||
|
|
||||||
2026-05-20 — Reliability improvements
|
2026-05-20 — Reliability improvements
|
||||||
* The app now rides through brief database hiccups instead of restarting, reducing momentary unavailability
|
* The app now rides through brief database hiccups instead of restarting, reducing momentary unavailability
|
||||||
* Background monitoring jobs recover cleanly after a restart instead of getting stuck
|
* Background monitoring jobs recover cleanly after a restart instead of getting stuck
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,13 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
import ExUnit.CaptureLog
|
import ExUnit.CaptureLog
|
||||||
|
|
||||||
alias Towerops.RedisHealthCheck
|
alias Towerops.RedisHealthCheck
|
||||||
|
alias Towerops.Redix.Fake
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Ensure the Fake is in its default (healthy) state before each test.
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :ok)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
describe "wait_for_redis/2" do
|
describe "wait_for_redis/2" do
|
||||||
test "returns :ok when Redis is available" do
|
test "returns :ok when Redis is available" do
|
||||||
|
|
@ -12,10 +19,13 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
assert :ok = RedisHealthCheck.wait_for_redis(redis_config, max_attempts: 2, backoff: 100)
|
assert :ok = RedisHealthCheck.wait_for_redis(redis_config, max_attempts: 2, backoff: 100)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns error after max attempts with unreachable Redis" do
|
test "returns error after max attempts when Redis is unreachable" do
|
||||||
redis_config = [host: "127.0.0.1", port: 9999]
|
redis_config = [host: "127.0.0.1", port: 6379]
|
||||||
|
|
||||||
|
# Make the Fake simulate connection errors so wait_for_redis exhausts
|
||||||
|
# its retries and returns {:error, :max_attempts_exceeded}.
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :error)
|
||||||
|
|
||||||
# Suppress Redis connection warnings during test
|
|
||||||
_logs =
|
_logs =
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
assert {:error, :max_attempts_exceeded} =
|
assert {:error, :max_attempts_exceeded} =
|
||||||
|
|
@ -28,11 +38,13 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "retries with exponential backoff" do
|
test "retries with exponential backoff" do
|
||||||
redis_config = [host: "127.0.0.1", port: 9999]
|
redis_config = [host: "127.0.0.1", port: 6379]
|
||||||
|
|
||||||
|
# Simulate failure so the retry loop runs.
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :error)
|
||||||
|
|
||||||
start_time = System.monotonic_time(:millisecond)
|
start_time = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
# Suppress Redis connection warnings during test
|
|
||||||
_logs =
|
_logs =
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
RedisHealthCheck.wait_for_redis(redis_config,
|
RedisHealthCheck.wait_for_redis(redis_config,
|
||||||
|
|
@ -48,8 +60,7 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
# With backoff of 10ms and 2 attempts, total wait should be at least:
|
# With backoff of 10ms and 2 attempts, total wait should be at least:
|
||||||
# attempt 1: fail + wait 10ms
|
# attempt 1: fail + wait 10ms
|
||||||
# attempt 2: fail
|
# attempt 2: fail
|
||||||
# Total: ~10ms + connection timeouts
|
# Total: ~10ms + processing time
|
||||||
# Just verify some time passed (exponential backoff is working)
|
|
||||||
assert elapsed >= 10
|
assert elapsed >= 10
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -61,20 +72,11 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
assert :ok = RedisHealthCheck.check_health(redis_config)
|
assert :ok = RedisHealthCheck.check_health(redis_config)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns error for unavailable Redis" do
|
test "returns error when Redis is unreachable" do
|
||||||
redis_config = [host: "127.0.0.1", port: 9999]
|
redis_config = [host: "127.0.0.1", port: 6379]
|
||||||
|
|
||||||
# Suppress Redis connection warnings during test
|
Fake.set_ping_response(Towerops.Redix, :error)
|
||||||
_logs =
|
|
||||||
capture_log(fn ->
|
|
||||||
assert {:error, _reason} = RedisHealthCheck.check_health(redis_config, 10)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "handles malformed Redis config" do
|
|
||||||
redis_config = [host: "not-a-valid-hostname-at-all-12345", port: 6379]
|
|
||||||
|
|
||||||
# Suppress Redis connection warnings during test
|
|
||||||
_logs =
|
_logs =
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
assert {:error, _reason} = RedisHealthCheck.check_health(redis_config, 10)
|
assert {:error, _reason} = RedisHealthCheck.check_health(redis_config, 10)
|
||||||
|
|
@ -82,13 +84,13 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "accepts custom timeout" do
|
test "accepts custom timeout" do
|
||||||
redis_config = [host: "127.0.0.1", port: 9999]
|
redis_config = [host: "127.0.0.1", port: 6379]
|
||||||
|
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :error)
|
||||||
|
|
||||||
# Suppress Redis connection warnings during test
|
|
||||||
_logs =
|
_logs =
|
||||||
capture_log(fn ->
|
capture_log(fn ->
|
||||||
result = RedisHealthCheck.check_health(redis_config, 10)
|
result = RedisHealthCheck.check_health(redis_config, 10)
|
||||||
# Should return error (either immediate connection failure or timeout)
|
|
||||||
assert match?({:error, _}, result)
|
assert match?({:error, _}, result)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
@ -110,34 +112,44 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "check_health/2 with a password" do
|
describe "check_health/2 with a password" do
|
||||||
test "passes the :password option through and fails auth" do
|
test "ignores password and succeeds when the persistent connection is healthy" do
|
||||||
# Bogus password against a Redis that doesn't require auth → AUTH error.
|
# The health check now uses the persistent Towerops.Redix connection,
|
||||||
# Either way the password keyword arm is exercised.
|
# which is already authenticated. The password in redis_config is ignored.
|
||||||
redis_config = [host: "127.0.0.1", port: 9999, password: "wrong"]
|
redis_config = [host: "127.0.0.1", port: 6379, password: "bogus"]
|
||||||
|
|
||||||
_logs =
|
assert :ok = RedisHealthCheck.check_health(redis_config, 10)
|
||||||
capture_log(fn ->
|
|
||||||
assert match?({:error, _}, RedisHealthCheck.check_health(redis_config, 10))
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "ignores empty-string password" do
|
test "ignores empty-string password" do
|
||||||
redis_config = [host: "127.0.0.1", port: 9999, password: ""]
|
redis_config = [host: "127.0.0.1", port: 6379, password: ""]
|
||||||
|
|
||||||
_logs =
|
assert :ok = RedisHealthCheck.check_health(redis_config, 10)
|
||||||
capture_log(fn ->
|
|
||||||
assert match?({:error, _}, RedisHealthCheck.check_health(redis_config, 10))
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "wait_for_redis/2 — successful retry" do
|
describe "wait_for_redis/2 — successful retry" do
|
||||||
test "logs 'connection established' when first attempt fails but second succeeds" do
|
test "logs 'connection established' when first attempt fails but second succeeds" do
|
||||||
# Hard to deterministically simulate a transient failure; we just confirm
|
# Simulate one failure then recovery.
|
||||||
# the success path returns :ok when Redis is up. Together with the
|
Fake.set_ping_response(Towerops.Redix, :error)
|
||||||
# max-attempts test above this hits both branches around the attempt counter.
|
|
||||||
|
# Schedule recovery after a short delay — the first attempt will fail,
|
||||||
|
# then we restore the healthy state before the second attempt.
|
||||||
|
Task.async(fn ->
|
||||||
|
Process.sleep(5)
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :ok)
|
||||||
|
end)
|
||||||
|
|
||||||
redis_config = Application.get_env(:towerops, :redis, host: "localhost", port: 6379)
|
redis_config = Application.get_env(:towerops, :redis, host: "localhost", port: 6379)
|
||||||
assert :ok = RedisHealthCheck.wait_for_redis(redis_config, max_attempts: 1)
|
|
||||||
|
_logs =
|
||||||
|
capture_log(fn ->
|
||||||
|
assert :ok =
|
||||||
|
RedisHealthCheck.wait_for_redis(redis_config,
|
||||||
|
max_attempts: 3,
|
||||||
|
backoff: 5,
|
||||||
|
timeout: 10
|
||||||
|
)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
defmodule ToweropsWeb.HealthControllerTest do
|
defmodule ToweropsWeb.HealthControllerTest do
|
||||||
use ToweropsWeb.ConnCase, async: false
|
use ToweropsWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
alias Towerops.Redix.Fake
|
||||||
|
|
||||||
describe "GET /health/live" do
|
describe "GET /health/live" do
|
||||||
test "returns 200 with status ok", %{conn: conn} do
|
test "returns 200 with status ok", %{conn: conn} do
|
||||||
conn = get(conn, "/health/live")
|
conn = get(conn, "/health/live")
|
||||||
|
|
@ -80,9 +82,12 @@ defmodule ToweropsWeb.HealthControllerTest do
|
||||||
|
|
||||||
test "returns 503 when redis is configured but unreachable", %{conn: conn} do
|
test "returns 503 when redis is configured but unreachable", %{conn: conn} do
|
||||||
previous = Application.get_env(:towerops, :redis)
|
previous = Application.get_env(:towerops, :redis)
|
||||||
# Point at a closed port on a routable but unreachable address.
|
Application.put_env(:towerops, :redis, host: "localhost", port: 6379)
|
||||||
# 198.51.100.0/24 is TEST-NET-2 (RFC 5737) — guaranteed not routable on the public internet.
|
|
||||||
Application.put_env(:towerops, :redis, host: "198.51.100.1", port: 1, timeout: 50)
|
# Make the Fake return connection errors for PING so we exercise
|
||||||
|
# the Redis-down branch without trying to open a real socket.
|
||||||
|
# The Fake is registered as `Towerops.Redix`, not by its module name.
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :error)
|
||||||
|
|
||||||
try do
|
try do
|
||||||
conn = get(conn, "/health")
|
conn = get(conn, "/health")
|
||||||
|
|
@ -94,6 +99,8 @@ defmodule ToweropsWeb.HealthControllerTest do
|
||||||
assert result["database"] == "connected"
|
assert result["database"] == "connected"
|
||||||
assert result["redis"] == "disconnected"
|
assert result["redis"] == "disconnected"
|
||||||
after
|
after
|
||||||
|
Fake.set_ping_response(Towerops.Redix, :ok)
|
||||||
|
|
||||||
if previous do
|
if previous do
|
||||||
Application.put_env(:towerops, :redis, previous)
|
Application.put_env(:towerops, :redis, previous)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue