Skip to content

Commit 93e7f40

Browse files
BxnnyGclaude
andcommitted
Etappe 54: „down" ist ein Status, keine Diagnose
Direkt aus dem Ausfall vom 16.–18.08. MatrixCtrl hat vier Komponenten als down gemeldet — korrekt, binnen Sekunden, 37 Stunden lang — und nie gesagt, warum. Der Scheduler hat den Grund die ganze Zeit in einem FailedScheduling-Event veröffentlicht. „Ist es kaputt" beantwortet kubectl get pods schon. Wofür ein Admin-Werkzeug da ist, ist der Satz „es kann nicht platziert werden, weil es 8500m auf einem 6000m-Node anfordert". Zwischen diesen beiden Sätzen liegen die 37 Stunden. Der Teil, der mehr ist als eine Event-Meldung auszugeben, ist die Rechnung — und die hat zwei Fallen, die dieses Projekt auf die harte Tour kennengelernt hat. Der Request eines Pods ist nicht die Summe seiner Container, sondern max(Summe(Container), Maximum(Init-Container)): Synapses render-config und db-wait hatten je 4000m geerbt, während der synapse-Container 1000m anforderte — Synapse reservierte also 4000m, während es nur auf die Datenbank wartete. Und ein resources-Block kann mehrere Container abdecken, weshalb aus 4000m bei postgres 8000m wurden. Zwei bewusste Grenzen: „größer als jeder Node" wird von „Cluster gerade voll" getrennt, weil nur Letzteres sich von selbst lösen kann. Und es wird kein Wert vorgeschlagen — was eine Komponente anfordern sollte, hängt davon ab, was sonst noch laufen soll. Nicht enthalten ist die vorbeugende Hälfte (Warnung beim Speichern der Config). Dafür müsste die Chart gerendert werden, weil die Container-Topologie eine Eigenschaft der Chart ist und nicht der bearbeiteten YAML. Als P1-16b vermerkt statt geraten. Ende-zu-Ende gegen einen absichtlich nicht planbaren Pod geprüft, dessen 40000m in einem Init-Container hinter einem 100m-Container steckten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 948ef63 commit 93e7f40

10 files changed

Lines changed: 504 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,32 @@ matching image, so a version identifies one exact pair
1515
1616
## [Unreleased]
1717

18+
## [0.1.52] — 2026-08-30
19+
20+
### Added
21+
22+
- **A component that is `down` now says why, when the reason is the scheduler.** The
23+
panel showed four components down for 37 hours during the outage of 2026-08-16…18
24+
without once mentioning that postgres was asking for more CPU than the node had —
25+
the scheduler had been saying so in a `FailedScheduling` event the whole time. The
26+
dashboard now carries the scheduler's own words plus the arithmetic: the pod's
27+
effective request against the largest node's allocatable.
28+
- Pods that ask for more than any single node can provide are called out separately.
29+
A full cluster may place them later; these never will, and telling someone to wait
30+
is worse than saying nothing.
31+
32+
### Notes
33+
34+
- The effective request is `max(sum(containers), max(initContainers))`, not the sum of
35+
the containers. Synapse's init containers had inherited 4000m each while its own
36+
container asked for 1000m, so it reserved 4000m while merely waiting for the
37+
database — a naive sum would have reported 1000m and made the diagnosis look wrong.
38+
- No suggested value. What a component *should* request depends on what else is meant
39+
to run on the node; naming the arithmetic is the panel's job, choosing the number is
40+
the operator's.
41+
- Verified against a live unschedulable pod whose 40000m request sat in an init
42+
container behind a 100m app container, not by unit test alone.
43+
1844
## [0.1.51] — 2026-08-17
1945

2046
### Fixed

deploy/helm/matrixctrl/Chart.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ apiVersion: v2
22
name: matrixctrl
33
description: Admin layer for self-hosted Matrix / Element Server Suite (ESS)
44
type: application
5-
version: 0.1.51
6-
appVersion: "0.1.51"
5+
version: 0.1.52
6+
appVersion: "0.1.52"
77
keywords:
88
- matrix
99
- element

docs/BACKLOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,16 @@ implemented, OIDC state consumed atomically via `DELETE … RETURNING` (CSRF-saf
10631063
that last case, where it costs one login.
10641064

10651065
- **P1-16 · A component can be `down` for 37 hours without the panel saying why (S4).**
1066+
**First half done 2026-08-30 (E54, [DESIGN.md §4.54](DESIGN.md)).** A Pending
1067+
component now says why, with the scheduler's own words and the effective request
1068+
against the node's allocatable — `max(sum(containers), max(initContainers))`, which
1069+
is what hid 4000m of Synapse's reservation inside an init container. Verified against
1070+
a live unschedulable probe pod, not only by unit test.
1071+
*Still open as **P1-16b**: the preventive half* — warning at config-save time. It
1072+
needs the chart's container topology (which containers share a `resources` block,
1073+
which init containers inherit it), so it means rendering the chart with the Helm SDK
1074+
rather than reading the values file. Recorded rather than guessed.
1075+
*Original entry:*
10661076
From the outage of 2026-08-16…18 ([DESIGN.md §4.53](DESIGN.md)): postgres was
10671077
unschedulable after the node shrank from 32 cores to 6, and MatrixCtrl reported
10681078
`down` — correctly and immediately — with no cause attached. The reason was in a

docs/DESIGN.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,3 +1955,47 @@ placed* is a different fault from one that is crashing, and the arithmetic that
19551955
it (requests vs allocatable) is available locally.
19561956

19571957
Recorded as **P1-16**. Affects S4, S14.
1958+
1959+
### §4.54 — "down" is a status, not a diagnosis (2026-08-30, agent, etappe 54)
1960+
1961+
Built directly from §4.53. During that outage the panel reported four components
1962+
`down` — correctly, within seconds, for 37 hours — and never said *why*. The scheduler
1963+
had been publishing the reason in a `FailedScheduling` event the entire time.
1964+
1965+
`kubectl get pods` already answers "is it broken". The reason an admin tool exists is
1966+
to answer **"it cannot be placed, because it asks for 8500m on a 6000m node"**. The gap
1967+
between those two sentences is where 37 hours went.
1968+
1969+
What makes this more than printing an event message is the arithmetic, and the
1970+
arithmetic has two traps that this project met the hard way:
1971+
1972+
**A pod's request is not the sum of its containers.** It is
1973+
`max(sum(containers), max(initContainers))`, because init containers run alone and
1974+
first. Synapse's `render-config` and `db-wait` had each inherited 4000m while the
1975+
`synapse` container asked for 1000m — so Synapse reserved 4000m the whole time it was
1976+
*merely waiting for the database*. A naive sum reports 1000m and makes the diagnosis
1977+
look wrong.
1978+
1979+
**One `resources` block can cover several containers.** `postgres.resources` applied to
1980+
postgres *and* postgres-ess-updater, so 4000m written once reserved 8000m. A
1981+
per-container number understates the pod by half.
1982+
1983+
Verified end to end rather than by unit test alone: a deliberately unschedulable probe
1984+
pod, whose 40000m request sat in an *init* container behind a 100m app container, was
1985+
diagnosed live at 40000m against 6000m allocatable — the max-of-init path exercised on
1986+
real cluster data, then the probe deleted.
1987+
1988+
Two deliberate limits. `ExceedsNode` separates "larger than any node" from "the cluster
1989+
is full right now", because only the second one can resolve itself — telling an
1990+
operator to wait for a pod that can never fit is worse than saying nothing. And the
1991+
panel does not propose a number: what postgres *should* request depends on what else
1992+
the operator intends to run, so naming the arithmetic is the job and choosing the value
1993+
is theirs.
1994+
1995+
The preventive half — warning at config-save time, before the value becomes an outage
1996+
at the next reboot — is **not** here, and the reason is worth recording: knowing what a
1997+
config *would* request means knowing the chart's container topology (which containers
1998+
share a block, which init containers inherit it), which is a property of the chart and
1999+
not of the YAML being edited. MatrixCtrl has the Helm SDK and can render it; inferring
2000+
it from the values file would be exactly the guessing this project refuses. P1-16b.
2001+
Affects S4.

docs/ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Etappes 1–10 are **reconstructed from `git log`** (39 commits, 2026-05-27 →
5959
| 19 | Calling — the ports that must be forwarded, and an explicit "this half is not checkable from here" | ✅ 2026-08-01 · `v0.1.19` · [plan](plans/etappe-19-calling-reachability.md) |
6060
| 32 | Release Notes auf der Upgrade-Seite + Version aus der Liste übernommen — die andere Hälfte der Pin-Warnung | ✅ 2026-08-05 · `v0.1.33` · [plan](plans/etappe-32-release-notes.md) |
6161
| 33 | OIDC-Init wiederholen statt einmalig aufgeben — ein Neustart vor MAS sperrte den Operator 11 h aus dem eigenen Panel aus | ✅ 2026-08-06 · `v0.1.34` · [plan](plans/etappe-33-oidc-retry.md) |
62+
| 54 | „down" ist ein Status, keine Diagnose — warum ein Pod nicht eingeplant werden kann, mit der Rechnung dahinter | 🔄 gebaut 2026-08-30 · `v0.1.52` · [plan](plans/etappe-54-why-it-cannot-be-placed.md) |
6263
| 53 | Das lauteste Element der Seite war doppelt falsch — „postgres in Restart-Schleife", obwohl postgres nie neu gestartet ist | ✅ 2026-08-17 · `v0.1.51` · [plan](plans/etappe-53-restart-banner.md) |
6364
| 52 | Einmal verbinden, und da landen wo man war — der Rücksprung hing am State, der Auto-Reconnect an seiner Schleifenbremse | ✅ 2026-08-17 · `v0.1.50` · [plan](plans/etappe-52-silent-reconnect.md) |
6465
| 51 | Der UDP-Puffer, den die SFU anfordert und nicht bekommt — und der Zähler, der aus dem falschen Namespace gelesen worden wäre | ✅ 2026-08-17 · `v0.1.49` · [plan](plans/etappe-51-udp-buffer-preflight.md) |
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Etappe 54 — `down` is not a diagnosis
2+
3+
P1-16, straight out of the outage of 2026-08-16…18 ([DESIGN.md §4.53](../DESIGN.md)).
4+
5+
MatrixCtrl reported postgres, Synapse, MAS and haproxy as `down`. Correctly, and
6+
within seconds. For **37 hours**. What it never said was *why* — and the reason was in
7+
a `FailedScheduling` event the whole time, one API call from data the panel was already
8+
fetching.
9+
10+
`kubectl get pods` already says "it is broken". An admin tool exists to say
11+
**"it cannot be placed, because it asks for 8500m on a 6000m node"**. That sentence is
12+
the etappe.
13+
14+
## What makes this more than reading an event message
15+
16+
Kubernetes' own message is `0/1 nodes are available: 1 Insufficient cpu.` — true, and
17+
not enough to act on. It does not say how much was asked for, or how much there is.
18+
Both are knowable here, and the arithmetic is where the two traps live:
19+
20+
**A pod's request is not the sum of its containers.** It is
21+
`max(sum(containers), max(initContainers))`. Synapse's `render-config` and `db-wait`
22+
had inherited 4000m each, so Synapse reserved 4000m the entire time it was *only
23+
waiting for the database* — while its own `synapse` container asked for 1000m. Summing
24+
containers would have reported 1000m and made the diagnosis look wrong.
25+
26+
**One `resources` block can cover several containers.** `postgres.resources` applies to
27+
postgres *and* postgres-ess-updater, so 4000m written once reserved 8000m. Reporting
28+
the per-container number would understate it by half.
29+
30+
So the panel computes the *effective* request the scheduler actually uses, and shows it
31+
against the node's allocatable. That is the number that explains the failure.
32+
33+
## Scope
34+
35+
**Ships:** for a component whose pods are Pending, why — the scheduler's own reason,
36+
and for the `Insufficient cpu|memory` case the effective request against allocatable.
37+
Rendered where the component already says `down`.
38+
39+
**Does not ship: warning at config-save time.** The valuable half, and the honest
40+
reason it is not here: to know what a config *would* request, the chart has to be
41+
rendered — the topology (which containers share a `resources` block, which init
42+
containers inherit it) is a property of the chart, not of the YAML being edited.
43+
MatrixCtrl has the Helm SDK and could dry-run it, but that is a second etappe with its
44+
own failure modes, and guessing the topology from the values file is exactly the kind
45+
of inference this project refuses. Stays P1-16b.
46+
47+
**Does not ship: suggesting a fix.** "Reduce postgres to 750m" depends on what else the
48+
operator intends to run. Naming the arithmetic is the job; choosing the number is
49+
theirs.
50+
51+
## Definition of done
52+
53+
- A Pending component says why, with the scheduler's reason
54+
- For insufficient cpu/memory it shows effective request vs allocatable
55+
- The effective request is `max(sum(containers), max(initContainers))` — asserted by a
56+
test built on the real shape that caused the outage
57+
- A component that is `down` for any other reason is unaffected
58+
- Nothing is claimed when there is no event to read: absent is not "fine"
59+
- `make check` green

internal/k8s/health.go

Lines changed: 32 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ type ComponentHealth struct {
2929
// LastRestart is when the most recent restart happened, absent when nothing has
3030
// restarted. It is what turns a bare count into something an operator can judge.
3131
LastRestart *time.Time `json:"last_restart,omitempty"`
32+
// Unschedulable says *why* a down component is down, when the answer is "the
33+
// scheduler refused it". Nil for every other kind of failure — a component that is
34+
// crashing must not be described as unplaceable (etappe 54).
35+
Unschedulable *Unschedulable `json:"unschedulable,omitempty"`
3236
}
3337

3438
// ComponentHealth reports every ESS workload. StatefulSets matter as much as
@@ -47,16 +51,21 @@ func (c *Client) ComponentHealth(ctx context.Context, namespace string) ([]Compo
4751
desired = *d.Spec.Replicas
4852
}
4953
restarts, by, looping, lastRestart := c.podRestarts(ctx, namespace, d.Spec.Selector.MatchLabels)
54+
var why *Unschedulable
55+
if workloadStatus(d.Status.ReadyReplicas, desired) == "down" {
56+
why = c.WhyUnschedulable(ctx, namespace, d.Spec.Selector.MatchLabels)
57+
}
5058
result = append(result, ComponentHealth{
51-
Name: d.Name,
52-
Kind: "Deployment",
53-
Status: workloadStatus(d.Status.ReadyReplicas, desired),
54-
Ready: d.Status.ReadyReplicas,
55-
Desired: desired,
56-
Restarts: restarts,
57-
RestartsBy: by,
58-
Looping: looping,
59-
LastRestart: lastRestart,
59+
Name: d.Name,
60+
Kind: "Deployment",
61+
Status: workloadStatus(d.Status.ReadyReplicas, desired),
62+
Ready: d.Status.ReadyReplicas,
63+
Desired: desired,
64+
Restarts: restarts,
65+
RestartsBy: by,
66+
Looping: looping,
67+
LastRestart: lastRestart,
68+
Unschedulable: why,
6069
})
6170
}
6271

@@ -70,16 +79,21 @@ func (c *Client) ComponentHealth(ctx context.Context, namespace string) ([]Compo
7079
desired = *s.Spec.Replicas
7180
}
7281
restarts, by, looping, lastRestart := c.podRestarts(ctx, namespace, s.Spec.Selector.MatchLabels)
82+
var why *Unschedulable
83+
if workloadStatus(s.Status.ReadyReplicas, desired) == "down" {
84+
why = c.WhyUnschedulable(ctx, namespace, s.Spec.Selector.MatchLabels)
85+
}
7386
result = append(result, ComponentHealth{
74-
Name: s.Name,
75-
Kind: "StatefulSet",
76-
Status: workloadStatus(s.Status.ReadyReplicas, desired),
77-
Ready: s.Status.ReadyReplicas,
78-
Desired: desired,
79-
Restarts: restarts,
80-
RestartsBy: by,
81-
Looping: looping,
82-
LastRestart: lastRestart,
87+
Name: s.Name,
88+
Kind: "StatefulSet",
89+
Status: workloadStatus(s.Status.ReadyReplicas, desired),
90+
Ready: s.Status.ReadyReplicas,
91+
Desired: desired,
92+
Restarts: restarts,
93+
RestartsBy: by,
94+
Looping: looping,
95+
LastRestart: lastRestart,
96+
Unschedulable: why,
8397
})
8498
}
8599

0 commit comments

Comments
 (0)