Skip to content

fix(observability): remove impossible Loki healthcheck that hung the stack - #8

Merged
Gerrrt merged 3 commits into
mainfrom
fix/loki-healthcheck-distroless
Aug 2, 2026
Merged

fix(observability): remove impossible Loki healthcheck that hung the stack#8
Gerrrt merged 3 commits into
mainfrom
fix/loki-healthcheck-distroless

Conversation

@Gerrrt

@Gerrrt Gerrrt commented Aug 2, 2026

Copy link
Copy Markdown
Owner

What changed

Loki no longer declares a healthcheck, and Grafana and Alloy now wait on it with condition: service_started instead of service_healthy. A new scripts/check_compose_health.py guard makes it hard to reintroduce the bug, and secrets/*.sops.yaml is now ignored by yamllint.

Why

make up hung on the monitoring host. Every container came up except Loki, which sat in starting forever with nothing logged.

grafana/loki is built FROM gcr.io/distroless/static:nonroot — the image contains /usr/bin/loki and nothing else. No shell, no wget, no curl. A Docker healthcheck can only exec something inside the container, so the wget --spider probe could never succeed no matter how healthy Loki was.

That is not cosmetic. Grafana and Alloy both waited on Loki via depends_on: condition: service_healthy, so neither ever started, and the stack appeared to deadlock with no error surfaced anywhere. Loki itself was fine the entire time; only the probe was impossible.

Readiness is now observed from outside the container instead:

  • curl -s localhost:3100/ready on demand
  • continuously by Prometheus, which already scrapes loki:3100 as the loki job — InstanceDown{job="loki"} and LokiIngestionStalled both cover it

Downgrading the dependencies to service_started is safe for both dependants: Grafana resolves its Loki datasource lazily per query, and Alloy retries a failed push with backoff while buffering in the meantime. The worst case is an empty panel for a few seconds.

Two follow-ons rode along:

  • scripts/check_compose_health.pydocker compose config accepts a service_healthy dependency on a service with no healthcheck without complaint, which is exactly why CI stayed green while the stack deadlocked. The guard closes both halves of the trap: every service_healthy target must actually declare a healthcheck, and no healthcheck may exec a binary on an image with no userland to run it. The second half needs an explicit list — an image reference says nothing about its base, and grafana/loki contains no "distroless" substring to match on. Wired into CI, scripts/validate.sh, and make check-compose-health.
  • yamllint ignore for secrets/*.sops.yaml — committing the encrypted secrets file turned the Lint job red on main. SOPS output is machine-generated ciphertext; it does not follow the repo's comment and indentation conventions and never will.

Blast radius

The observability stack on prometheus (10.0.99.20, VLAN 99). Startup ordering only — no config change to any service, no data path touched.

  • No change to network segmentation or firewall rules
  • No new port published to a VLAN that could not already reach the service
  • No credential added outside secrets/*.sops.yaml

Verification

Locally, against the pinned images and real binaries:

compose.yaml OK — 4 healthcheck(s), 2 service_healthy dependency/dependencies, all satisfiable
docker compose config -q     → exit 0
yamllint --strict .          → clean (red before the ignore)
markdownlint-cli2            → 0 issues in 21 files
shellcheck scripts/*.sh      → clean
check_dashboards.py          → 5 dashboards OK (79 panels, 79 PromQL expressions)

The guard matters only if it fails when it should, so both regression paths were tested against fixtures.

Re-pointing Grafana back at loki: condition: service_healthy:

grafana waits for loki to become healthy, but loki declares no healthcheck — it
can never report healthy, so grafana will hang forever          (exit 1)

Re-adding a wget healthcheck to Loki — the subtler path, since it satisfies the check above and hangs the stack anyway:

loki has a healthcheck exec'ing 'wget', but grafana/loki:3.7.4@sha256:… has no
shell and no userland — the probe can never run, so loki stays 'starting'
forever                                                          (exit 1)

Image classification is covered by a 13-case table over bare, tagged, digest-pinned and registry-qualified references, asserting grafana/loki and explicit distroless/scratch bases are flagged while the busybox, alpine and ubuntu based images in this stack are not. The PyYAML fallback was exercised both ways in a throwaway venv: installs and passes where pip is available, and reports PyYAML is required and could not be installed rather than a traceback where it is not.

  • make validate passes
  • Deployed to the lab and confirmed working — pending; git pull && make up on the monitoring host is the remaining step
  • Docs updated (inline rationale lives in compose.yaml next to the removed healthcheck, where the next person tempted to add one will read it)

…stack

grafana/loki is built FROM gcr.io/distroless/static:nonroot — the image
contains /usr/bin/loki and nothing else. No shell, no wget, no curl. A Docker
healthcheck can only exec something inside the container, so the wget probe
could never succeed and Loki sat in "starting" forever.

That is not cosmetic. Grafana and Alloy both waited on Loki via
`depends_on: condition: service_healthy`, so neither ever started and `make up`
appeared to hang with no error logged anywhere. Loki itself was healthy the
whole time; only the probe was impossible.

- drop the healthcheck from loki, with a comment explaining why one cannot
  exist and where readiness is observed instead (curl :3100/ready, plus
  Prometheus scraping the loki job — InstanceDown and LokiIngestionStalled
  already cover it)
- grafana and alloy now depend on loki with condition: service_started.
  Grafana resolves its Loki datasource lazily per query; Alloy retries pushes
  with backoff and buffers meanwhile. Racing Loki's startup is safe for both.

Add scripts/check_compose_health.py so this cannot come back. It asserts that
every service_healthy dependency targets a service that actually declares a
healthcheck, and flags healthchecks that exec a binary on an image that looks
distroless. `docker compose config` accepts both happily, which is why the
existing CI was silent.

Wired into CI, scripts/validate.sh and `make check-compose-health`.

Also ignore secrets/*.sops.yaml in .yamllint.yaml. SOPS output is
machine-generated ciphertext that does not follow the comment and indentation
conventions and never will; committing the encrypted file turned the lint job
red on main.
Copilot AI review requested due to automatic review settings August 2, 2026 22:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a compose startup deadlock in the observability stack by removing Loki’s unsatisfiable healthcheck and changing Grafana/Alloy to no longer wait on Loki becoming “healthy”. It also adds a CI guard to prevent unsatisfiable service_healthy dependency graphs and updates yamllint ignores for encrypted SOPS files.

Changes:

  • Remove Loki healthcheck and switch Grafana/Alloy depends_on from service_healthy to service_started.
  • Add scripts/check_compose_health.py and wire it into scripts/validate.sh, CI, and make check-compose-health.
  • Ignore secrets/*.sops.yaml in .yamllint.yaml to avoid lint failures on machine-generated ciphertext.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
stacks/observability/compose.yaml Removes Loki healthcheck and adjusts dependency conditions to prevent stack hang.
scripts/validate.sh Runs the new compose health dependency check as part of local validation.
scripts/check_compose_health.py Adds a guard script to detect unsatisfiable compose health dependencies/healthchecks.
Makefile Adds a check-compose-health target.
.yamllint.yaml Ignores SOPS-encrypted secret YAML files.
.github/workflows/ci.yml Runs the new compose health dependency guard in CI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/check_compose_health.py Outdated
Comment on lines +22 to +28
import pathlib
import sys

try:
import yaml
except ModuleNotFoundError:
sys.exit("PyYAML is required: python3 -m pip install pyyaml")

# Images with no shell and no userland. A healthcheck cannot exec anything in
# these beyond the service binary itself.
DISTROLESS_MARKERS = ("distroless", "/static", "scratch")
Comment thread scripts/validate.sh
Comment thread scripts/check_compose_health.py Outdated
Gerrrt added 2 commits August 2, 2026 22:51
Review caught that the guard did not actually cover its own motivating case.
The distroless detection matched on substrings in the image reference —
"distroless", "/static", "scratch" — but the image that caused the outage is
`grafana/loki`, which contains none of them. Re-adding a wget healthcheck to
Loki would have satisfied the service_healthy check and hung the stack again,
with the guard silent.

An image reference says nothing about its base, so known no-userland images
have to be listed by name. NO_USERLAND_IMAGES holds grafana/loki, verified
against its published Dockerfile, and the substring markers stay for images
that name their own base. Matching is on the repository component and accepts
registry-qualified forms, so docker.io/grafana/loki and
registry.local:5000/grafana/loki resolve the same way.

Verified with a 13-case table over the reference forms, and against a fixture
that re-adds the wget healthcheck to Loki — previously silent, now:

  loki has a healthcheck exec'ing 'wget', but grafana/loki:3.7.4@sha256:... has
  no shell and no userland — the probe can never run, so loki stays 'starting'
  forever

Also from review:

- Install PyYAML if absent instead of exiting. The script now gates CI, and a
  clean runner without the library would have failed a perfectly good compose
  file. Matches what check_loki_rules.sh already does. Exercised both ways in a
  throwaway venv: installs and passes where pip exists, and reports "PyYAML is
  required and could not be installed" rather than a traceback where it does not.
- Replace `int(bool(print(...))) or 1` with a plain print and return.
- Rename the validate.sh label to "compose health dependencies", so the failure
  line no longer reads "FAIL health dependencies satisfiable".
A bytecode cache directory landed in the previous commit — importing
check_compose_health.py to unit-test its image classification created
scripts/__pycache__/, and it was swept in by `git add -A`.

Remove it from the index and ignore __pycache__/ and *.py[cod], which the
repository never had a pattern for despite carrying two Python scripts.
@Gerrrt
Gerrrt merged commit 54252e4 into main Aug 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants