Kubernetes Health Checks

@benzene/health-checks provides purpose-built liveness/readiness convenience functions on top of the general-purpose health checks support, matching Kubernetes' own liveness/readiness/ startup probe model. This page covers the semantics, how to wire them up per transport, and example probe configuration. Read Health Checks first if you haven't — this page assumes you know IHealthCheck / IHealthCheckBuilder and doesn't repeat that material.

Liveness vs. readiness

Kubernetes' guidance is specific about what belongs in each, and Benzene doesn't second-guess it:

Caution — don't reflexively put every external-dependency check in readiness. A check against a shared downstream (a queue, a database, an API every replica talks to) is shared-fate: all replicas run the same check against the same dependency, so a transient blip fails all their readiness probes at once and Kubernetes pulls every pod from the Service. The Service then has zero endpoints — callers get connection-refused / DNS failures instead of a structured 503, turning a degradation into a total outage. De-routing only helps when some replicas are healthy to shed to; for a shared dependency there are none. Gate readiness on a dependency only when you've reasoned it's a hard, synchronous dependency you truly cannot serve any traffic without. When in doubt, use the deep healthcheck layer instead (below).

For a dependency you have reasoned is safe to gate on, register it explicitly under readiness — e.g. @benzene/health-checks-http's addHttpPing, @benzene/health-checks-tcp's addTcpPing, or your own check via useReadinessCheck(...).

The deep healthcheck layer, not a probe

Point a Kubernetes probe at liveness/readiness, and your monitoring/mesh at the general healthcheck topic — not the other way around. The healthcheck layer (useHealthCheck) is meant to be scraped by monitoring, the mesh, or humans; it triggers no automated Kubernetes action, so a rich set of external-dependency checks there gives you visibility without the shared-fate cascading-failure risk above.

Client / contract-drift checks belong in neither probe

A generated client's contract-drift check (@benzene/clients-health-checks' ClientHealthCheck / addContractCheck) is not an ordinary external-dependency check, and it does not belong in a liveness or a readiness probe. It calls a downstream provider's health endpoint and compares contract hashes, so it fails the two tests above harder than a database check does:

So keep these checks off the liveness/readiness probes. Scrape them from monitoring / the mesh instead.

Porting note. The .NET library ships a dedicated probe-less contracts topic and a UseContractsCheck middleware for exactly this. The TypeScript port has the ClientHealthCheck and addContractCheck/addContractCheckInstance builder helpers (@benzene/clients-health-checks) but no useContractsCheck middleware yet — register the contract checks on a topic of your own via the general useHealthCheck and scrape it directly, until the dedicated middleware is ported.

Topic-based wiring (every transport): useLivenessCheck / useReadinessCheck

Both are free functions taking the pipeline builder first, generic over TContext, so they work on any transport:

export function useLivenessCheck<TContext>(
  app: IMiddlewarePipelineBuilder<TContext>,
  config: HealthCheckConfig,
): IMiddlewarePipelineBuilder<TContext>;

export function useReadinessCheck<TContext>(
  app: IMiddlewarePipelineBuilder<TContext>,
  config: HealthCheckConfig,
): IMiddlewarePipelineBuilder<TContext>;

config is the same HealthCheckConfig the general useHealthCheck takes — an array of instances, a builder-configuring callback, or a prebuilt IHealthCheckBuilder.

import { useLivenessCheck, useReadinessCheck } from '@benzene/health-checks';
import { addHttpPing } from '@benzene/health-checks-http';
import { addTcpPing } from '@benzene/health-checks-tcp';

useLivenessCheck(app, (checks) =>
  checks.addHealthCheck(ProcessResponsiveCheck)); // your own check: cheap, no external I/O

useReadinessCheck(app, (checks) => {
  addTcpPing(checks, 'db.internal', 5432);        // a dependency you've reasoned is safe to gate on
  addHttpPing(checks, 'https://downstream/health');
});

Each responds only to its own topic — Constants.defaultLivenessTopic ('liveness') and Constants.defaultReadinessTopic ('readiness'). Unlike useHealthCheck, neither also matches Constants.defaultHealthCheckTopic ('healthcheck'). This is deliberate: if both did, whichever was registered first in the pipeline would silently swallow every request for the shared 'healthcheck' topic, and the other would never run for it.

HTTP-path wiring (Express and other HTTP hosts)

On an HTTP host the topic is resolved from the request's method + path before the health middleware runs, via the route table. There is no dedicated path overload in the port (the .NET Benzene.Aws.Lambda.ApiGateway package's UseLivenessCheck/UseReadinessCheck HTTP-path overloads default to /livez and /readyz) — instead, register an IHttpEndpointDefinition mapping each conventional probe path to the liveness/readiness topic, then wire the middleware by topic:

import express from 'express';
import { benzene } from '@benzene/express';
import { DefaultBenzeneServiceContainer } from '@benzene/dependencies';
import { HttpEndpointDefinition, IHttpEndpointDefinition } from '@benzene/http';
import { useLivenessCheck, useReadinessCheck, Constants } from '@benzene/health-checks';
import { addHttpPing } from '@benzene/health-checks-http';

const container = new DefaultBenzeneServiceContainer();
// Map the conventional Kubernetes probe paths onto the liveness/readiness topics.
container.addSingletonFactory(
  IHttpEndpointDefinition,
  () => new HttpEndpointDefinition('GET', '/livez', Constants.defaultLivenessTopic),
);
container.addSingletonFactory(
  IHttpEndpointDefinition,
  () => new HttpEndpointDefinition('GET', '/readyz', Constants.defaultReadinessTopic),
);

const app = express();
app.use(
  benzene((pipeline) => {
    useLivenessCheck(pipeline, (checks) => checks.addHealthCheck(ProcessResponsiveCheck));
    useReadinessCheck(pipeline, (checks) => addHttpPing(checks, 'https://downstream/health'));
  }, { container }),
);
app.listen(8080);

Registering the route is what lets the Express host hand GET /livez / GET /readyz to Benzene at all — an unmatched path falls straight through to the rest of your Express app. No message-handler registration is needed for these routes: the liveness/readiness middleware intercepts the request directly by topic, before any handler resolution would run.

Example Kubernetes manifest

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-benzene-service
spec:
  template:
    spec:
      containers:
        - name: my-benzene-service
          image: my-registry/my-benzene-service:latest
          ports:
            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /livez
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10

Both probes work correctly against Benzene's response because the HTTP status code — not just the JSON body — reflects health: 200 when healthy, 503 Service Unavailable when not (see HTTP status codes). Kubernetes' httpGet probe type only inspects the status code (2xx–3xx is a pass, anything else a failure), so this matters — a health check that returned 200 unconditionally, with the real status only in the body, would never actually fail a Kubernetes probe.

What this does not cover

See also