Packages

Kubernetes liveness and readiness probes for Elixir/Phoenix using OTP's native shutdown sequence.

Current section

Files

Jump to
kubernetes_probes lib kubernetes_probes drainer.ex
Raw

lib/kubernetes_probes/drainer.ex

defmodule KubernetesProbes.Drainer do
@moduledoc """
Holds the k8s readiness-probe status and enforces a drain window on shutdown.
On `SIGTERM`, OTP walks the supervision tree in reverse start order and
terminates children in reverse order. This `GenServer` **must be the last
child** of your application supervisor so it terminates first. Its
`terminate/2` flips the `/probe/readiness` probe to 503 via `:persistent_term`
and sleeps for the drain window, giving Kubernetes time to stop routing
traffic and allowing in-flight requests to finish before other resources
(Repo, Endpoint, etc.) are torn down.
## Usage
# lib/my_app/application.ex — last entry in children
children = [
MyApp.Repo,
MyAppWeb.Endpoint,
{KubernetesProbes.Drainer, otp_app: :my_app}
]
## Configuration
# config/config.exs — production default
config :my_app, KubernetesProbes.Drainer, wait: 20_000
# config/dev.exs — avoid 20s hang on Ctrl-C
config :my_app, KubernetesProbes.Drainer, wait: 100
# config/test.exs — avoid slow suite teardown
config :my_app, KubernetesProbes.Drainer, wait: 10
## Options
* `:otp_app` — the OTP application to read configuration from (required).
* `:wait` — drain window in milliseconds. Can be set via config (see above)
or passed directly to override config: `{KubernetesProbes.Drainer, otp_app: :my_app, wait: 5_000}`.
Defaults to `20_000`.
"""
use GenServer
require Logger
@default_wait 20_000
@shutdown_slack 5_000
def child_spec(opts) do
wait = resolve_wait(opts)
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
shutdown: wait + @shutdown_slack,
restart: :permanent,
type: :worker
}
end
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)
@spec status() :: :running | :stopping
def status, do: :persistent_term.get({__MODULE__, :status}, :running)
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
:persistent_term.put({__MODULE__, :status}, :running)
{:ok, %{wait: resolve_wait(opts)}}
end
@impl true
def terminate(_reason, %{wait: wait}) do
Logger.info("#{__MODULE__}: draining for #{wait}ms")
:persistent_term.put({__MODULE__, :status}, :stopping)
Process.sleep(wait)
:ok
end
defp resolve_wait(opts) do
otp_app = Keyword.get(opts, :otp_app)
app_wait = otp_app && Application.get_env(otp_app, __MODULE__, []) |> Keyword.get(:wait)
Keyword.get(opts, :wait, app_wait || @default_wait)
end
end