Documentation
¶
Overview ¶
Package health implements healthchecks for distroless containers.
Docker's HEALTHCHECK needs a command inside the container, and distroless images have no curl/wget/shell. This package covers the two shapes that problem takes:
- File marker (Marker, RunProbe): for containers whose main process is your own Go binary. The running process touches the file at DefaultPath at lifecycle points; the probe process (the same binary re-invoked with a `health` subcommand) stats it. The app owns the health decision via Set.
- HTTP probe (ProbeHTTP, RunHTTPProbe, cmd/probe): for containers that wrap a third-party server which cannot cooperate with a Marker but already exposes an HTTP endpoint whose reachability IS the health signal. The standalone cmd/probe binary is installed into the image and wired as the HEALTHCHECK.
When you own the main process, prefer the file marker: Set expresses application state a network GET cannot. The rest of this doc comment describes the file-marker mode.
Failure modes:
- If the marker directory is not writable (typically compose declares `read_only: true` without a `tmpfs: /tmp` mount), the constructor logs one Warn with a fix hint and enters degraded mode. In degraded mode the long-running process treats Set / Cleanup as no-ops. The probe process independently detects the same condition and reports healthy, because the container is alive and the only broken piece is the signaling channel. Reporting unhealthy would trigger a Docker restart loop that cannot fix a compose misconfiguration.
- Transient failures during Set are logged at Warn but do not change the marker's mode. A failed Set that leaves the marker absent on a still-writable directory (e.g. directory churn) surfaces at the next probe as unhealthy. A failure whose cause also leaves the directory unwritable (full tmpfs), and a failed Set(false) that leaves the marker present, are both reported healthy by the probe, matching the degraded-mode rationale above.
Logging goes through slog.Default(); configure it via slog.SetDefault in main before constructing a Marker.
Thread-safe; Set may be called from any goroutine.
Example ¶
Example demonstrates the two-process healthcheck pattern. The long-running process creates a marker; the probe process stats it.
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/cplieger/health"
)
func main() {
path := filepath.Join(os.TempDir(), ".healthy-example")
m := health.NewMarker(path)
defer m.Cleanup()
m.Set(true)
fmt.Println("healthy:", m.Healthy())
m.Set(false)
fmt.Println("healthy:", m.Healthy())
}
Output: healthy: true healthy: false
Index ¶
- Constants
- func HTTPProbeCheck(w io.Writer, timeout time.Duration, urls ...string) int
- func Handler(s Signal) http.Handler
- func ProbeCheck(path string) int
- func ProbeDir(path string) error
- func ProbeHTTP(ctx context.Context, url string) error
- func RunHTTPProbe(timeout time.Duration, urls ...string)
- func RunProbe(path string)
- type Marker
- type Signal
- type Status
Examples ¶
Constants ¶
const DefaultHTTPProbeTimeout = 5 * time.Second
DefaultHTTPProbeTimeout is the default total wall-clock budget for one HTTP probe run (all URLs together). Five seconds matches the classic BusyBox-wget healthcheck recipes this probe replaces and sits below Docker's default HEALTHCHECK --timeout, so a hung endpoint fails with a written reason instead of a SIGKILL from the runtime.
const DefaultPath = "/tmp/.healthy"
DefaultPath is the default marker location. Docker healthchecks stat this path; the app creates and removes it at lifecycle points. /tmp is conventional because compose services with read_only:true typically mount /tmp as tmpfs.
Variables ¶
This section is empty.
Functions ¶
func HTTPProbeCheck ¶ added in v1.2.0
HTTPProbeCheck probes every URL within one shared timeout budget and returns 0 when all succeed, 1 otherwise, writing one line per failure to w. It deliberately probes ALL URLs rather than stopping at the first failure, so a multi-surface healthcheck (e.g. a serving route plus an admin endpoint) reports every broken surface in one run.
Zero URLs is reported unhealthy: an empty probe answering "healthy" would silently mask a misconfigured HEALTHCHECK. A non-positive timeout fails immediately for the same reason.
Example ¶
ExampleHTTPProbeCheck shows the multi-URL probe behind cmd/probe: all URLs must answer 2xx within one shared timeout.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"github.com/cplieger/health"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
code := health.HTTPProbeCheck(os.Stderr, health.DefaultHTTPProbeTimeout, srv.URL)
fmt.Println("exit code:", code)
}
Output: exit code: 0
func Handler ¶
Handler returns an http.Handler that reports the health of the given Signal as a JSON object. Returns 200 with {"status":"OK"} when healthy, 503 with {"status":"Unavailable"} otherwise. This mirrors the response shape of hellofresh/health-go and satisfies K8s HTTP probe expectations.
If s is nil, the handler always reports unhealthy (503).
The handler is optional — import and wire it only if your container exposes an HTTP endpoint alongside the file-marker probe.
Note: in degraded mode (unwritable marker directory) Marker.Healthy() returns false, so this endpoint reports 503 -- intentionally diverging from the `health` subcommand probe (ProbeCheck), which reports healthy to avoid a Docker restart loop (see package doc). Do not wire this endpoint as the sole liveness probe on a service that may run with a read-only filesystem and no /tmp tmpfs, or it will restart-loop a container that is actually alive.
func ProbeCheck ¶
ProbeCheck implements the health-probe decision without calling os.Exit, so it can be unit-tested. Returns 0 for healthy or degraded, 1 for unhealthy.
Example ¶
ExampleProbeCheck shows how to use ProbeCheck for a testable probe that does not call os.Exit.
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/cplieger/health"
)
func main() {
dir, _ := os.MkdirTemp("", "health-example-*")
defer os.RemoveAll(dir)
path := filepath.Join(dir, ".healthy")
// No marker yet — writable dir means unhealthy.
fmt.Println("code:", health.ProbeCheck(path))
// Create marker — healthy.
os.WriteFile(path, nil, 0o600)
fmt.Println("code:", health.ProbeCheck(path))
}
Output: code: 1 code: 0
func ProbeDir ¶ added in v1.1.0
ProbeDir reports whether the marker's parent directory is writable by creating and deleting a temp file: nil when writable, the underlying error otherwise. This is the exact check NewMarker and ProbeCheck use internally to decide degraded mode; it is exported so consumers (and their tests) can assert marker-directory writability without copying the probe into their own package.
func ProbeHTTP ¶ added in v1.2.0
ProbeHTTP performs a single liveness GET against url and returns nil when the final response (after following redirects) has a 2xx status. Any transport error, context expiry, or non-2xx status is an error.
This is the HTTP counterpart of the file-marker probe, for containers that wrap a third-party server (Caddy, a reverse proxy, an upstream daemon) which cannot cooperate with a Marker but already exposes an HTTP endpoint whose reachability IS the health signal. When you own the main process, prefer the file marker: the app aggregates its own state via Set, which a network GET cannot express.
Example ¶
ExampleProbeHTTP shows the HTTP liveness probe for containers that wrap a third-party server exposing an HTTP endpoint.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/cplieger/health"
)
func main() {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
err := health.ProbeHTTP(context.Background(), srv.URL)
fmt.Println("healthy:", err == nil)
}
Output: healthy: true
func RunHTTPProbe ¶ added in v1.2.0
RunHTTPProbe runs in the probe process (a Docker HEALTHCHECK exec) and exits 0 when every URL answers 2xx within the shared timeout, 1 otherwise, with each failure written to stderr. The HTTP counterpart of RunProbe; cmd/probe is the ready-made binary around it.
func RunProbe ¶
func RunProbe(path string)
RunProbe runs in the separate `health` subcommand process. It exits 0 if the marker is present or the marker directory is unwritable (degraded mode: the long-running process cannot signal through the filesystem, so the probe falls back to "alive"). It exits 1 when the marker is absent from a writable directory, which is the real unhealthy signal.
Types ¶
type Marker ¶
type Marker struct {
// contains filtered or unexported fields
}
Marker implements the file-based distroless healthcheck pattern. Use NewMarker to construct it; call Set(bool) at lifecycle points; defer Cleanup on shutdown; call RunProbe from main when os.Args[1] is "health".
func NewMarker ¶
NewMarker constructs a marker for path and probes the parent directory for writability. On failure it logs a single Warn with a fix hint and returns a marker in degraded mode; callers need not branch on the result.
func (*Marker) Cleanup ¶
func (m *Marker) Cleanup()
Cleanup removes the marker. Typically called via defer at shutdown. In degraded mode Cleanup is a no-op.
func (*Marker) Healthy ¶
Healthy reports whether the marker file currently exists. Satisfies the Signal interface so HTTP handlers can report liveness without reaching into a package global. Strict os.Stat: a degraded marker directory (read-only mount, missing tmpfs) causes Healthy to return false so the HTTP endpoint honestly reports unhealthy.
In degraded mode this intentionally diverges from ProbeCheck, which returns 0 (healthy) to avoid a Docker restart loop. Healthy returns false because HTTP consumers deserve an honest signal; see package doc.