web / health checks

I run health checks on web services during deploy and every minute to verify availability.

Servers serve GET /healthz. It returns 200 ok on success and 503 db unavailable on failure. The body is the same for every fault, so a public caller learns nothing about the internals. The server writes the real error to its journal. Cache-Control: no-store prevents stale responses. A two-second timeout prevents slow dependencies from hanging callers. The endpoint takes no credentials and reads minimal state.

Check the dependency, not the handle

SELECT 1 proves that the server runs SQL over the network. It touches no table, so a dropped table or a missing grant still returns 200.

I read one row from a core table instead:

SELECT 1 FROM games LIMIT 1

One row proves that the table exists and the app can read it. I use LIMIT 1 because count(*) scans the whole table and adds no signal.

An empty table is a pass, because the query still ran. An empty games table is a board with no games yet. In Go, that makes sql.ErrNoRows a pass.

Cache in memory to protect against floods

An external caller can flood an unauthenticated endpoint and exhaust database connections, causing health checks to time out.

I cache database check results in memory for two seconds. A double-checked sync.RWMutex serializes checks so the database receives at most one query every two seconds. If a client cancels its context, the handler reports an error without caching that cancellation:

type health struct {
	mu        sync.RWMutex
	checkedAt time.Time
	err       error
}

func (h *handler) serveHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Cache-Control", "no-store")
	if err := h.checkDB(r.Context()); err != nil {
		log.Printf("healthz: %v", err)
		http.Error(w, "db unavailable", 503)
		return
	}
	_, _ = io.WriteString(w, "ok\n")
}

func (h *handler) checkDB(ctx context.Context) error {
	h.health.mu.RLock()
	if time.Since(h.health.checkedAt) < 2*time.Second {
		err := h.health.err
		h.health.mu.RUnlock()
		return err
	}
	h.health.mu.RUnlock()

	h.health.mu.Lock()
	defer h.health.mu.Unlock()
	if time.Since(h.health.checkedAt) < 2*time.Second {
		return h.health.err
	}

	queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	var n int
	err := h.db.QueryRowContext(queryCtx, "SELECT 1 FROM games LIMIT 1").Scan(&n)
	if errors.Is(err, sql.ErrNoRows) {
		err = nil
	}
	if err == nil {
		h.health.checkedAt = time.Now()
		h.health.err = nil
		return nil
	}
	if ctx.Err() != nil {
		return err
	}
	h.health.checkedAt = time.Now()
	h.health.err = err
	return err
}

A static server checks liveness only

A server that serves files has no database. Its check is liveness: 200 ok proves that the process serves HTTP.

Ready is not healthy

A process that just started can answer /healthz before its first data refresh ends. My sports app puts that fact in a header on the same answer: X-Board-Fresh: 1 after the first refresh, and 0 before it. The status stays 200, so a monitor reads the process as healthy. Only the deploy reads the header.

What reads the answer

The sports app runs in a guest with two slots, and one slot takes the traffic. A deploy boots the new build in the idle slot.

The deploy checks from the same machine, because that is where the new process runs. It asks the idle slot at its own address, before the proxy sends it any traffic. The check proves that the new process is well. It does not prove that the internet can reach the service.

for i in $(seq 120); do
  if hdr=$(curl -sf -m 1 -D - -o /dev/null "$guest/healthz") &&
    page=$(curl -sf -m 2 "$guest/") && [[ $page == *Search* ]]; then
    [[ $hdr == *X-Board-Fresh:\ 1* ]] && return 0
  fi
  sleep 0.5
done

The deploy also reads the home page, because a 200 from /healthz does not prove that the page renders. After the first good answer, it waits up to 30 seconds more for the fresh header. A board that is still stale then does not stop the deploy, because the old slot reads the same feeds.

If the idle slot does not answer, the deploy stops it, points back at the previous build, and exits with an error. The old slot keeps the traffic. If the idle slot answers, the deploy points the proxy at it. It stops the old slot when the proxy shows no request in flight there.

A watchdog on the host probes the live slot of each guest every minute. It restarts a slot after three failed probes in a row. An exact 503 db unavailable is not a failed probe: the process answers and the database does not, so a restart would not help.

An outside monitor requests the public /healthz through the CDN. It proves what the local checks cannot: that DNS, the CDN, and the proxy reach the process. The generic body is what makes the public route safe to leave open.

← All articles