health

package module
v1.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 15, 2026 License: GPL-2.0, GPL-3.0 Imports: 13 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. cmd/probe is 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).

Install

Go: go get github.com/cplieger/health@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)
}
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/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 health.ProbeHTTP(ctx, url) / health.HTTPProbeCheck(w, timeout, urls...) / health.RunHTTPProbe(timeout, urls...).

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
  • (*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) — probe process entry (calls os.Exit)
  • ProbeCheck(path string) int — testable probe logic (0=healthy, 1=unhealthy)
  • ProbeDir(path string) error — reports whether the marker's parent directory is writable (the degraded-mode check NewMarker/ProbeCheck use internally, exported for consumers and their tests)
  • DefaultHTTPProbeTimeout — default shared budget for one HTTP probe run (5s)
  • ProbeHTTP(ctx context.Context, url string) error — single HTTP liveness GET; nil on a 2xx final response
  • HTTPProbeCheck(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)
  • RunHTTPProbe(timeout time.Duration, urls ...string) — HTTP probe process entry (calls os.Exit); 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.
Marker staleness / mtime checks Docker's --interval/--timeout handle staleness at the orchestrator level.
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 (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

Examples

Constants

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

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 HTTPProbeCheck added in v1.2.0

func HTTPProbeCheck(w io.Writer, timeout time.Duration, urls ...string) int

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

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) 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 ProbeDir added in v1.1.0

func ProbeDir(path string) error

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

func ProbeHTTP(ctx context.Context, url string) error

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

func RunHTTPProbe(timeout time.Duration, urls ...string)

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

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.

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
cmd
probe command
Command probe is a standalone HTTP liveness probe for containers without a shell.
Command probe is a standalone HTTP liveness probe for containers without a shell.

Jump to

Keyboard shortcuts

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