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 (the nested module github.com/cplieger/health/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 probe/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.
- By default the probe checks existence only; staleness belongs to Docker's --interval at the orchestrator level. Apps whose resident loop refreshes the marker each cycle can opt into a freshness deadline with WithMaxAge, under which a wedged loop (marker present but old) probes unhealthy. See WithMaxAge for when not to arm it.
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 ¶
Examples ¶
Constants ¶
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 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 ¶
func ProbeCheck(path string, opts ...ProbeOption) int
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 RunProbe ¶
func RunProbe(path string, opts ...ProbeOption)
RunProbe runs in the separate `health` subcommand process. It exits 0 if the marker is present (and fresh, when WithMaxAge is armed) 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 or stale past an armed deadline, which are the real unhealthy signals; the stderr diagnostic names the underlying stat failure when the cause is something other than absence.
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.
func (*Marker) Set ¶
Set records the current liveness state and touches or removes the marker accordingly. Edge transitions (true↔false) are logged; repeated calls with the same value are silent. Safe to call from any goroutine. In degraded mode Set is a no-op. A filesystem failure is logged and swallowed; use SetChecked to observe it programmatically.
func (*Marker) SetChecked ¶ added in v1.4.0
SetChecked is Set with the filesystem outcome reported: it returns nil when the marker now reflects ok, and the underlying error when the touch or remove failed (the same failure Set logs and swallows, so no extra log line is emitted). It exists for callers whose own success contract includes the marker write — e.g. a one-shot scan subcommand whose exit code an external scheduler alerts on, where a silently lost heartbeat should fail the invocation loudly instead. In degraded mode it returns nil: the marker channel is deliberately inert there (see the package doc's failure modes), and propagating an error would turn a compose misconfiguration into the restart or alert loop the degraded design exists to avoid.
type ProbeOption ¶ added in v1.3.0
type ProbeOption func(*probeConfig)
ProbeOption configures the probe-side health decision (RunProbe and ProbeCheck). Without options the probe checks marker existence only.
func WithMaxAge ¶ added in v1.3.0
func WithMaxAge(d time.Duration) ProbeOption
WithMaxAge arms an opt-in freshness deadline: a marker older than d is unhealthy (exit 1), turning the signal from a level ("the app last reported healthy") into a lease ("the app recently proved progress"). The writing side needs no new calls: every Set(true) refreshes the marker's mtime, so an app that already calls Set(true) once per work cycle gets heartbeat semantics by passing this option to RunProbe in its health subcommand.
Arm it only where the resident process runs its own bounded work cycle at a known cadence, so a stale marker means a wedged loop that a restart fixes. Do NOT arm it for externally-triggered apps (a separate docker exec writes the marker): an idle resident between triggers is healthy, and restarting it cannot fix a trigger that stopped firing. Marker.Healthy (and therefore Handler) stays existence-based regardless of this option.
A non-positive d disables the deadline (same as omitting the option).