health

package module
v1.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: GPL-2.0, GPL-3.0 Imports: 11 Imported by: 0

README

health

Go Reference Go version Test coverage Mutation OpenSSF Best Practices OpenSSF Scorecard

Healthchecks for distroless containers: file marker + HTTP probe

A standalone Go library for Docker healthchecks in containers that lack a shell. Two modes:

  • File marker — for containers whose main process is your own Go binary. The running process touches/removes a marker file; the probe process (re-invoked binary) stats it. Handles degraded mode (read-only filesystem) gracefully.
  • HTTP probe — for containers wrapping a third-party server (Caddy, an upstream daemon) that cannot cooperate with a marker but already exposes an HTTP endpoint whose reachability is the health signal. Ships as its own nested module, github.com/cplieger/health/probe, with probe/cmd/probe as the ready-made static binary to bake into the image.

When you own the main process, prefer the file marker: Set(bool) expresses application state a network GET cannot. Standard library only (test dependency: pgregory.net/rapid).

The two modules version and release independently (vX.Y.Z tags for the marker library, probe/vX.Y.Z for the probe), so a marker-side release never forces a probe release or vice versa.

Install

Go: go get github.com/cplieger/health@latest

HTTP probe module: go get github.com/cplieger/health/probe@latest

Usage

Main process
package main

import "github.com/cplieger/health"

func main() {
    m := health.NewMarker(health.DefaultPath)
    defer m.Cleanup()

    // Mark healthy once ready
    m.Set(true)

    // ... run application ...
}
Health subcommand (probe process)
if len(os.Args) > 1 && os.Args[1] == "health" {
    health.RunProbe(health.DefaultPath)
}

External triggers and file ownership: the marker belongs to whoever created it. If a separate docker exec process updates it (a job scheduler invoking your binary's run/sync subcommand), run that exec as the same UID as the container's main process, e.g. user = 568:568 in an Ofelia job-exec block. A mismatched exec user fails the marker write with permission denied, and only the health signal is lost — silently under Set. A subcommand whose exit code an external scheduler alerts on can make that loss loud instead: call SetChecked and propagate the returned error into the exit code, so the scheduler's job fails rather than losing the heartbeat invisibly.

Freshness deadline (opt-in)

By default the probe checks existence only, and staleness stays owned by Docker's --interval. That check has a blind spot: once Set(true) has run, a deadlocked process keeps passing every probe. An app whose resident loop already calls Set(true) once per work cycle can arm a deadline, turning those calls into heartbeats — a marker older than the deadline probes unhealthy and Docker restarts the container:

if len(os.Args) > 1 && os.Args[1] == "health" {
    health.RunProbe(health.DefaultPath, health.WithMaxAge(3*interval))
}

Every Set(true) refreshes the marker's mtime, so the writing side needs no changes. Pick a max-age comfortably above one cycle interval plus the worst normal cycle duration (3× the interval is a sane default).

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. Healthy() and Handler stay existence-based regardless.

HTTP probe (wrapped third-party servers)

For images whose main process is not your code — so nothing can touch a marker — bake the standalone probe binary into the image and point it at the endpoint(s) that define liveness:

FROM golang:1.26-alpine AS probe
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/cplieger/health/probe/cmd/probe@latest

FROM gcr.io/distroless/static-debian12
COPY --from=probe /out/probe /probe
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
    CMD ["/probe", "http://127.0.0.1:2019/config/"]

Multiple URLs probe multiple surfaces in one run (all must answer 2xx within one shared -timeout budget, default 5s):

CMD ["/probe", "http://127.0.0.1:80/health", "http://127.0.0.1:2019/config/"]

Exit codes: 0 all healthy, 1 any probe failed (each failure written to stderr, visible in docker inspect), 2 usage error. From Go, the same logic is probe.URL(ctx, url) / probe.Check(w, timeout, urls...) / probe.Run(timeout, urls...) from the github.com/cplieger/health/probe module.

Optional HTTP handler (K8s HTTP probes)

For containers that also expose an HTTP endpoint, the library provides an optional Handler that emits JSON status — compatible with K8s HTTP liveness probes and mirroring the response shape of hellofresh/health-go:

import "github.com/cplieger/health"

m := health.NewMarker(health.DefaultPath)
http.Handle("/healthz", health.Handler(m))

Response (200 OK):

{"status":"OK","timestamp":"2025-01-01T00:00:00Z"}

Response (503 Service Unavailable):

{"status":"Unavailable","timestamp":"2025-01-01T00:00:00Z"}

Degraded mode caveat: when the marker directory is unwritable (e.g. read_only: true with no /tmp tmpfs), Handler reports 503, intentionally diverging from the health subcommand probe (ProbeCheck), which reports healthy to avoid a Docker restart loop. Do not wire Handler as the sole liveness probe on a service that may run read-only without a /tmp tmpfs, or it will restart-loop a container that is actually alive.

API

  • DefaultPath — default marker path (/tmp/.healthy)
  • Signal — interface with Healthy() bool
  • Marker — main type; implements Signal
  • NewMarker(path string) *Marker — constructor (probes dir writability)
  • (*Marker).Set(ok bool) — touch or remove marker (failures logged and swallowed)
  • (*Marker).SetChecked(ok bool) errorSet with the filesystem outcome reported; nil in degraded mode (the marker channel is deliberately inert there, so callers don't turn a compose misconfiguration into an alert loop)
  • (*Marker).Cleanup() — remove marker on shutdown
  • (*Marker).Healthy() bool — stat-based liveness check
  • Status — JSON response struct emitted by Handler (fields: Status, Timestamp)
  • Handler(s Signal) http.Handler — optional JSON health endpoint
  • RunProbe(path string, opts ...ProbeOption) — probe process entry (calls os.Exit)
  • ProbeCheck(path string, opts ...ProbeOption) int — testable probe logic (0=healthy, 1=unhealthy)
  • ProbeOption / WithMaxAge(d time.Duration) — opt-in freshness deadline for the probe side (marker older than d is unhealthy; non-positive d disables)

In the github.com/cplieger/health/probe module:

  • probe.DefaultTimeout — default shared budget for one HTTP probe run (5s)
  • probe.URL(ctx context.Context, url string) error — single HTTP liveness GET; nil on a 2xx final response
  • probe.Check(w io.Writer, timeout time.Duration, urls ...string) int — testable multi-URL probe (0=all healthy, 1 otherwise; probes all URLs, one failure line each; zero URLs is unhealthy)
  • probe.Run(timeout time.Duration, urls ...string) — probe process entry (calls os.Exit); probe/cmd/probe is the ready-made binary around it

Unsupported by design

The following features are deliberately excluded. This library complements HTTP-based health libraries (e.g. hellofresh/health-go, alexliesenfeld/health) rather than competing with them — those are server-side check frameworks, while this library's HTTP probe is a client-side liveness GET for the HEALTHCHECK side of the same connection.

Feature Rationale
Registered dependency checks Set(bool) is the aggregation point; the app owns the decision logic. A check registry is a fundamentally different abstraction (~150 LOC, specialized).
Liveness/readiness split Docker Compose has one HEALTHCHECK. For K8s, create two Marker instances with different paths.
Graceful shutdown / context.Context Cleanup() is the shutdown action. No background goroutines exist to cancel.
Status-change callbacks State transitions are logged via slog. Wrap Set() for custom callbacks.
Default staleness checking Existence-only remains the default; Docker's --interval owns cadence. Freshness is opt-in per app via WithMaxAge (see above), never global.
Prometheus metrics Trivially added by consumers: prometheus.NewGaugeFunc(opts, func() float64 { ... }).
Custom marker content The pattern's elegance is os.Stat — no parsing, no format versioning.

Disclaimer

This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.

This project was built with AI-assisted tooling using Claude Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.

License

GPL-3.0 — see LICENSE.

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

View Source
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

func Handler(s Signal) http.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

func NewMarker(path string) *Marker

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

func (m *Marker) Healthy() bool

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

func (m *Marker) Set(ok bool)

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

func (m *Marker) SetChecked(ok bool) error

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).

type Signal

type Signal interface {
	Healthy() bool
}

Signal is the interface satisfied by *Marker. Consumers (e.g. HTTP handlers) can depend on this interface without importing the concrete type.

type Status

type Status struct {
	Status    string `json:"status"`
	Timestamp string `json:"timestamp"`
}

Status is the JSON response emitted by Handler.

Directories

Path Synopsis
probe module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL