health

package module
v1.1.5 Latest Latest
Warning

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

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

README

health

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

File-based healthcheck for distroless containers

A standalone Go library implementing the file-marker health-signal pattern for Docker containers that lack a shell. The running process touches/removes a marker file; the probe process (re-invoked binary) stats it. Handles degraded mode (read-only filesystem) gracefully. 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)
}
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)

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.

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 file-based healthchecks for distroless containers.

Docker's HEALTHCHECK needs a command inside the container. Distroless images have no curl/wget/shell, so the canonical approach is to re-invoke the app binary with a `health` subcommand that probes the application's liveness. This package uses a file marker at DefaultPath: the running process touches the file at lifecycle points, the probe process stats it.

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

Jump to

Keyboard shortcuts

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