Container health view — deployed
Date: 2026-08-13 · Host: ai-ahu (192.168.83.20, VPN-only) · Status: live
URL: http://192.168.83.20:8320/containers (VPN) · API: GET /api/containers (executive+)
Spec: 2026-08-13-container-health-view-spec.md
The number
| at start | now | |
|---|---|---|
| containers | 41 | 43 |
| verified by a check that can actually fail | 18 (44%) | 37 (86%) |
| running with nobody checking | 23 | 5 |
| health claims that were fabricated | 1 | 0 |
"Verified" means a check ran and could have failed. It counts healthy and unhealthy — both are verdicts. It does not count "the process has not exited".
What is actually still unverified — 5, honestly
| container | why | fix |
|---|---|---|
ahu-gpu-server |
healthcheck written but not active — needs a container recreate | blocked, see below |
ahu-classifier |
idem (/ready) |
blocked, see below |
ahu-worker-akta |
idem (scoped celery inspect ping) |
blocked, see below |
ahu-worker-llm |
idem | blocked, see below |
ahu-signature-verify |
loopback-bound, not our compose file, unreachable from the observatory | needs its owner to bind it reachably or add its own healthcheck |
These render grey, not green. The dashboard says why on each card.
Blocker — the GPU driver on ai-ahu is mismatched
Found while preparing the last four healthchecks. No new GPU container can start on this host right now.
NVRM kernel module : 580.159.03
NVIDIA userspace : 580.173.02
$ nvidia-smi → Failed to initialize NVML: Driver/library version mismatch
$ docker run --gpus device=0 nvidia/cuda:12.4.0-base nvidia-smi -L
→ OCI runtime create failed: open /run/nvidia-persistenced/socket: no such file or directory
The libraries were upgraded; the loaded kernel module was not. Running GPU containers survive only because they hold device handles obtained before the upgrade.
I stopped rather than proceeding. Recreating ahu-gpu-server, ahu-classifier, ahu-worker-akta or ahu-worker-llm to activate their healthchecks would have taken all four down with no way to bring them back — OCR and the classifier gone until the host is fixed.
This is worth acting on independently of this feature: the host is one container restart away from losing every GPU service. Fixing it means reloading the NVIDIA kernel modules, which in practice means a reboot (the modules are in use). Once that is done, docker compose up -d --force-recreate gpu-server classifier worker-akta worker-llm in ~/ahu-ai activates the last four healthchecks and the unverified count drops to 1.
The fabricated healthcheck
ahu-azure-di-id-document was running:
healthcheck:
test: ["CMD-SHELL", "exit 0"]
A check that cannot fail. Docker reported it (healthy), and the board would have rendered it green on that basis — the exact failure mode this feature exists to prevent, sitting inside the data source.
It could not be fixed in place: that image ships with no shell, bash, curl, wget or python3, so Docker has nothing to run inside it. That is presumably why exit 0 was there. Its only dependent (azure-di-studio) uses the plain list form of depends_on, not condition: service_healthy, so removing it blocked nothing.
Removed, and replaced with an observatory-side probe on :5002/ready. The board now reports it as healthy with source probe observatory — a weaker, accurate claim instead of a stronger, false one.
Healthchecks added
Active now (container recreated, verified passing):
| container | check |
|---|---|
ahu-platform-gateway-1 |
wget /healthz on :8200 |
ahu-platform-redis |
redis-cli ping \| grep -q PONG |
ahu-redis |
redis-cli ping \| grep -q PONG |
ahu-observatory-observatory-1 |
wget /healthz on :8300 |
ahu-observatory-db |
pg_isready -U observatory |
ahu-observatory-dashboard |
wget / through nginx |
ahu-docker-proxy |
wget /containers/json |
keep-keep-backend-1 |
wget /healthcheck on :8080 |
keep-keep-frontend-1 |
wget http://$(hostname -i):3000/health |
keep-keep-websocket-server-1 |
curl :6001 |
Written, awaiting the driver fix: ahu-gpu-server (/health), ahu-classifier (/ready), ahu-worker-akta + ahu-worker-llm (scoped celery inspect ping).
Two details worth keeping:
redis-cli pingalone is not enough. It exits 0 even when the server answers with an error — e.g.LOADINGwhile an AOF replays. "Still loading" is not "ready to serve", hence thegrep -q PONG.- An unscoped
celery inspect pingis a fake green. It replies OK whenever any worker on the broker is alive, soworker-llmwould report healthy while dead ifworker-aktawere up.-d celery@$(hostname)scopes it to the container's own node. Verified: it returns non-zero for a node that does not exist.
Every command was run in-container before being written into compose, including a negative control to confirm it can actually fail.
Observatory-side probes (9) for containers whose compose we do not own: ai-ahu-rag, ahu-chatbot-orchestrator, ahu-chat-interface (obert's), ahu-dcgm-exporter, ahu-cleanup-llm, tei-qwen3-embed (bare docker run), ahu-paddle-ocr, ahu-azure-di-studio, ahu-azure-di-id-document (no shell in image).
Verified against reality, not fixtures
Stopped keep-keep-websocket-server-1 — genuinely non-critical (realtime UI only) but with a real dependency edge into the Internal lane.
before state=healthy source=docker_healthcheck internal lane: calm
stop ↓
after state=down source=docker_state status="Exited (0) 23 seconds ago"
internal lane: affected=True by=[keep-keep-websocket-server-1]
public/notaris/platform lanes: still calm
restart ↓
after state=healthy source=docker_healthcheck internal lane: calm again
The tile changed, the right lane lit up, and — as importantly — the other three lanes did not. A board that reddens everything is the same failure as one that greens everything.
Data source
tecnativa/docker-socket-proxy with CONTAINERS=1 and nothing else, no published ports, on the observatory's compose network. Verified after deploy:
GET /containers/json → 200 (the only thing allowed)
POST /containers/create → 403
POST /containers/ahu-vllm/stop → 403
GET /images/json → 403
GET /info → 403
GET /exec → 403
The raw Docker socket is never mounted into the observatory. It is root-equivalent on the host — anything that can reach it can start a privileged container that mounts / — and the observatory is a network-reachable HTTP service. The proxy holds the socket; the observatory gets one read-only endpoint.
The version trap
The Engine API returns the structured Health field only on the unversioned path:
GET /containers/json → "Health": {"Status":"healthy","FailingStreak":0} ✅
GET /v1.44/containers/json → no Health key at all ❌
A version prefix does not error — it silently omits health, and every container on the host would read as having no healthcheck. Startup now rejects a docker_url containing a path for exactly this reason, and the poller types Health as a pointer so "absent" and "none" stay distinguishable: absent falls back to parsing the status string, and if that is inconclusive the state is unknown, not unverified. "There is no healthcheck" and "we could not tell" are different facts.
Design notes
Seven state names, four colours. Four colours is the visual budget, but collapsing different situations into one name is how a dashboard starts lying. Tiles show the precise state; only the colour is bucketed.
| green | grey (no claim) | red | dark |
|---|---|---|---|
healthy |
unverified, starting, unknown |
unhealthy |
down, missing |
Only healthy is ever green, and an unrecognised state from a future server build falls through to grey — the default fails toward humility.
Blast radius is computed from authored edges, because Docker does not know them. Each edge carries a confidence: verified (read out of a real config or env, source noted inline in the registry) or assumed (plausible, unproven). A path is verified only if every edge on it is; one verified path beats any number of assumed ones. Assumed impacts are labelled (dugaan) in the UI. Most of the OCR→Azure-DI edges are assumed and shown as such.
Roughly 40 of the edges were upgraded from guesses to verified by reading gateway.yaml, observatory.yaml, and the containers' own environment variables rather than inferring from names.
Ignorance is not degradation. unverified/starting/unknown never trigger blast radius. Had they, all 23 unchecked containers would have lit every lane on day one and the feature would have been useless immediately.
Staleness is the honesty mechanism. If the poller stops writing, samples age out of the window, the API returns stale: true, every tile reads unknown, and a banner explains why — instead of a frozen board still showing the last green it saw.
Unclassified is load-bearing. ahu-docker-proxy appeared mid-deploy and surfaced immediately as an unclassified grey tile, which is exactly the intended behaviour: a container nobody mapped is the one that will break. It has since been classified.
Corrections applied (round 2)
The ahu-ai-chatbot and ahu-ocr-tidyup sessions checked their own source and
found parts of the registry wrong. Two would have made the board lie in the
specific way it exists to prevent — just inverted: crying outage instead of
crying fine.
Blast radius now distinguishes degraded from down
The public chatbot fails soft: every tool call is wrapped in try/catch +
timeout, failures become ToolResult{ok:false}, and compose() answers from
whatever survived. The page loads, the stream completes, HTTP is 200 — the
answer degrades. Encoding a lost RAG as "public is down" is false, and
painting it the same red as a real outage trains people to ignore the red.
Edges now carry effect: hard|soft; impacts carry severity: down|degraded.
A chain takes the far end down only if every link is hard; across paths the
worst outcome wins. Verified live on the deployed board:
| stop this | expected | measured |
|---|---|---|
ahu-platform-gateway-1 |
public + notaris + internal down | {public: down, notaris: down, internal: down} |
ahu-vllm |
public + internal down (no LLM, no answer) | {public: down, internal: down} |
ai-ahu-rag |
public + internal degraded, notaris untouched | {public: degraded, internal: degraded} |
milvus-standalone |
same as RAG | {public: degraded, internal: degraded} |
tei-qwen3-embed |
public + internal degraded | {public: degraded, internal: degraded} |
ahu-platform-redis |
degraded (cache/stream loss, not outage) | {public/notaris/internal: degraded} |
ahu-chatbot-orchestrator |
nothing (legacy, no consumers) | {} |
keep-keep-websocket-server-1 |
internal degraded (realtime only) | {internal: degraded} |
Demonstrated end-to-end by stopping the Keep websocket again: the Internal lane
now renders amber "Menurun karena" instead of red "Mati karena".
ai-ahu-rag was being probed by a liar
Its /health handler is literally return {"status":"ok"} — tagged "Liveness
probe" in its own source, answering in 0.65 ms without touching Milvus or
TEI. A green light over a dead RAG stack.
Replaced with POST /search {"query":"apostille","top_k":1}, requiring a
non-empty hits array — which exercises TEI-embed → Milvus for real
(measured 10.8–13.6 ms). Probes gained method, body, and response-body
assertions to make that expressible. "200 with zero hits" is exactly what a
dead index or a dead embedder looks like, and no status code can see it.
A bug my own verification caught: the router was a transitive conduit
After applying the corrections, ai-ahu-rag still reported notaris: down.
Cause: the gateway's routing targets were declared as its own depends_on,
so a RAG outage propagated hard through the gateway into the notaris lane —
which never touches RAG at all.
A router is not a monolith: one upstream dying does not kill it, other routes
keep serving. The gateway now declares only its own process dependency (Redis,
soft), and each consumer declares the upstreams it actually uses, directly.
Same reasoning softened two more false-hard paths: ahu-ai-workers (batch — a
dead dependency means a job retries later, not that staff lost a service) and
the observatory (survives losing Redis; does not survive losing its own DB).
Lane fixes
ahu-chat-interface moved public → internal: no nginx vhost on any host
proxies to it, it is LAN/VPN-only, and it is the staff SQL data-agent UI.
Marking it public means a 3am page for a dev UI no citizen can reach.
ahu-ai-workers → internal (staff-console batch; it now has a real healthcheck
as of ac28589). ahu-chatbot-orchestrator is labelled legacy, its probe
labelled liveness-not-readiness, and nothing depends on it — so its blast
radius is structurally zero, not merely small.
OCR storage: an auxiliary check, and it is currently red — correctly
Containers can now carry auxiliary checks: signals a service reports about
itself that are not its liveness. They render beside the tile and never feed
state or blast radius, because "the process is serving" and "its data is
consistent" are different questions.
GET /api/health/storage always returns 200 even when unhealthy, so the check
colours on the healthy field, not the status code, and bounds staleness
with ageMs (300 s) so a cached green cannot pass as a current one.
It currently reports unexpected status 404 — the running image is
ahu-ai-ocr:337266e0 (built 2026-08-11) and does not contain 07a3a73e. I
left it live rather than commenting it out: the 404 is true, it is visibly a
pending deploy rather than a storage fault, and it will go green by itself when
the OCR image ships. Hiding it would have made the gap invisible.
Unrelated finding, visible in the screenshot
The dashboard's existing alert bar shows "Error-rate spike — 100% of the last 15 min of AI calls failed (84 calls)". I checked whether my gateway restart caused it. It did not.
HTTP_400 failures on paddleocr / doc-classifier-svc / azure-di-onprem have been running all day, long before I touched the host:
| hour (UTC) | ok | HTTP_400 |
|---|---|---|
| 08:00 | 122 | 123 |
| 09:00 | 99 | 87 |
| 10:00 | 81 | 125 |
…and back through 03:00–05:00. My first host action was ~10:12 UTC and the gateway restarted at 10:21:26, well after the pattern was established. The "100%" figure is a windowing artifact: the only traffic in that particular 15 minutes was one failing OCR batch.
HTTP_400 is a client error — the OCR app is sending requests the upstreams reject, not an infrastructure outage — which is consistent with the upstreams themselves probing healthy.
Characterisation (cheap, so here it is)
Last 12 hours, status=error AND error_code=HTTP_400:
| upstream | surface | operation | calls | distinct users | distinct traces | window |
|---|---|---|---|---|---|---|
paddleocr |
internal | ocr |
442 | 1 | 409 | 04:07 → 13:58 |
doc-classifier-svc |
internal | classify |
49 | 2 | 49 | 04:07 → 13:58 |
azure-di-onprem |
internal | ocr |
13 | 1 | 13 | 08:19 → 13:58 |
It is one client. 88% of the errors are a single user_id on the
internal OCR surface hitting PaddleOCR's ocr operation. 409 distinct
trace_ids means 409 genuinely separate requests, not one request retrying —
so it is a caller issuing malformed requests repeatedly, all day, roughly
04:00–14:00 with a lull 06:00–07:00.
PaddleOCR itself is fine (:8108/health → 200 on every poll), so the upstream
is rejecting the payload, not failing. The next step is to look at what that
one client is sending — which needs the request bodies, i.e. real digging in
the OCR repo, not something to expand this task with. Route it separately.
Follow-ups
- Reboot
ai-ahuto resync the NVIDIA kernel module — then recreate the four GPU containers to activate their healthchecks. Independently important: GPU services currently cannot be restarted at all. ahu-signature-verify— needs its owner to expose a reachable readiness endpoint.- Awaited from other sessions:
ahu-ai-workersalready has a genuinely good healthcheck (it verifies live queue consumers exist, not just that Redis answers). Readiness endpoints for the three obert-owned services would let their probes become real healthchecks. No dashboard change is needed when a healthcheck appears — state is derived from what the API reports at poll time, so a tile flips grey→green on its own. - Migrate the registry to Docker labels (
ahu.audience=…) so the metadata travels with the container and cannot rot. Deferred: it means editing hands-off repos. - Row volume: 43 containers × 30s ≈ 124k rows/day in
container_samples(a hypertable). Fine, but worth a retention policy eventually.
Files
ahu-ai-observatory — internal/config/config.go (Containers block, apply/validate), internal/store/containers.go + migrations/007_containers.sql, internal/ingest/containers.go (poller + prober + deriveState), internal/api/containers.go (computeImpacts, handler), deploy/containers-registry.yaml (the 42-entry registry), deploy/compose.yaml (socket proxy + healthchecks), deploy/observatory.example.yaml.
ahu-observatory-dashboard — src/lib/containers/state.ts, src/views/ContainersView.tsx, src/lib/api.ts, src/lib/nav.ts, src/App.tsx, src/styles/carbon.scss.
Host-only — ~/ahu-ai/docker-compose.yml, ~/keep-deploy/compose.keep.yml (both backed up as .bak-pre-healthchecks).
Tests: Go 9 packages green; dashboard 50 files / 513 tests green, lint clean. The production registry is itself under test — a typo in it fails the build rather than silently dropping a lane out of a blast radius on the deployed host.