
Ein Vierteljahr lang die falsche Ursache
Eine Erklärung passte auf jedes Detail des Fehlers und war trotzdem falsch. Sie hielt sich drei Monate, weil sie nie gemessen, sondern nur abgeschrieben wurde.
Ein Container startete nicht. Im Log stand eine Zeile, immer dieselbe:
exec /app/bin/boot: no such file or directory
Das liest sich wie ein kaputtes Dockerfile. Die Datei fehlt im Image, also stimmt etwas mit der COPY-Anweisung nicht. Man schaut ins Dockerfile, findet nichts, schaut in den Build-Kontext, findet auch nichts.
Dann startet man das Image mit /bin/ls statt mit dem Entrypoint, und die Antwort lautet: stat /bin/ls: no such file or directory. Nicht der Entrypoint fehlt. Das ganze Dateisystem fehlt. Der Docker-Build hatte Erfolg gemeldet, ein Image mit plausibler Größe erzeugt, und dieses Image ist innen leer.
Dafür hatte ich seit Juli eine Erklärung. Sie passte auf jedes Detail und war trotzdem falsch, und ich habe ein Vierteljahr gebraucht, um das zu merken.
Im Juli passte die Erklärung zu gut
Damals traf dasselbe Symptom einen frisch aufgesetzten Server. Docker hatte kurz zuvor die Voreinstellung geändert: Neuinstallationen ab Version 29 legen Images im containerd-Store ab statt im klassischen. Und im Bugtracker von Docker lag ein offener Fehlerbericht über genau diesen Store, in dem der Image-Export bei großen Builds abbricht — moby/moby#52431, seit April offen.
Die Kette schloss sich sofort. Neuer Server, neuer Store, bekannter Fehler. Wir haben den klassischen Treiber in der daemon.json festgenagelt, neu gebaut, und das Problem war weg.
Was ich damals nicht getan habe: prüfen, ob die Erklärung stimmt. Sie passte, das Problem verschwand, und damit war der Fall erledigt.
Wie eine Vermutung zur Tatsache wird
Sie wird aufgeschrieben. Zuerst als Kommentar im Setup-Skript, direkt über der Zeile, die den alten Treiber setzt. Dieselbe Begründung stand danach in den Projektnotizen, in der Betriebsdokumentation und in mehreren Commit-Nachrichten.
Keine dieser Stellen war eine Messung; jede war eine Abschrift der vorherigen.
Am deutlichsten wird das an einem Satz aus meinen Notizen: Der Pin darf nicht entfernt werden, bis der Upstream-Bug geschlossen ist. Das klingt nach Sorgfalt. Es heißt aber, dass ich den Zustand meiner eigenen Server von einem fremden Bugtracker abhängig gemacht hatte.
Dazu kommt mein Werkzeug. Ich arbeite mit Claude als KI-Assistent, und er liest genau diese Dateien. Als das Symptom im August wiederkam, schlug er einen Neustart des Servers vor, sauber begründet mit den Notizen vom Juli. Ob die Notizen stimmen, hat er nicht gefragt.
Das ist kein Fehler des Modells. Es stellt Konsistenz mit dem her, was es vorfindet. Ist das Vorgefundene falsch, wird der Fehler dadurch nur gründlicher verteilt.
Im August stimmte die Erklärung nicht mehr
Der Server, den es diesmal erwischte, lief seit einem Monat auf dem alten Treiber. Genau dem, der das Problem verhindern sollte.
Damit war die These eigentlich schon tot. Ein Schutz, der das Problem nicht verhindert, ist keiner. Trotzdem hätte ich es fast wieder mit einem Erklärungsversuch statt mit einer Messung gelöst, denn der Neustart klang plausibel und im Kernel-Log gab es passende Meldungen dazu.
Die Frage, die den Knoten löste, kam von einem Menschen. Sie lautete sinngemäß: Kann es nicht sein, dass das ein einmaliger Ausrutscher war und der neue Store längst in Ordnung ist?
Ein Nachmittag Messen gegen ein Vierteljahr Vermuten
Der Aufbau war simpel. Eine Wegwerf-Instanz mit Docker 29.7.2, zwei Durchläufe, je fünf Runden mit einem Image von 2,2 Gigabyte und 22 Schichten, gebaut kalt, warm und nach dem Aufräumbefehl, den unser Update-Werkzeug ohnehin ausführt.
Zwei Dinge daran waren wichtiger als der Aufbau selbst.
Erstens habe ich die Entscheidungsregeln vorher aufgeschrieben, samt der Ergebnisse, die gegen mich ausgehen würden. Läuft der neue Store fehlerfrei durch, fliegt der Pin raus. Fallen beide Durchläufe durch, war er nie die Heilung. Wer die Auswertungsregel erst nach den Zahlen festlegt, findet immer eine, die zur eigenen These passt.
Zweitens gab es einen Kontrollarm. Der neue Store hätte auch aus Versehen sauber laufen können.
Das Ergebnis: fünf von fünf fehlerfrei auf dem neuen Store, keine einzige Kernelmeldung. Der Kontrollarm mit dem alten Treiber ebenfalls fünf von fünf. Beide Stores bauen brauchbare Images, und die hohlen Images kommen von keinem von beiden.
Der Pin kam zurück, aus einem anderen Grund
Am nächsten Tag habe ich den echten Anwendungsfall gemessen statt eines Testbildes: den vollständigen Build unserer Anwendung, mit vorab lokal liegenden Archiven, so wie es im Betrieb läuft. Drei kalte und zwei warme Runden je Durchlauf, dieselbe Maschine.
Kalt 14 Sekunden gegen 37. Der Export allein 3,5 gegen 19,7. Und die Zeile, die entschied: Der warme Build, der eigentlich Sekundenbruchteile dauern sollte, brauchte auf dem neuen Store dieselben 36 Sekunden wie der kalte. Sein Build-Cache überlebt den Aufräumbefehl nicht, den unser Werkzeug nach jedem Update ausführt. Das ist meine Messung auf einer Maschine, keine dokumentierte Eigenschaft des Produkts.
Also steht der alte Treiber wieder im Setup-Skript. Aus einem gemessenen Grund, mit den Zahlen daneben, und mit einem Schalter, der ihn abstellt.
Die ursprüngliche Ursache der hohlen Images ist bis heute unbekannt. Ich habe eine Vermutung, die zu den Zeitstempeln passt, und ich habe sie als Vermutung in die Notizen geschrieben, mit dem Befehl daneben, der sie beim nächsten Auftreten bestätigt oder widerlegt.
Das ist der einzige Unterschied zu Juli.
Die Messwerte
Zwei Durchläufe an zwei Tagen, beide auf derselben Wegwerf-Instanz: Debian 13, Docker 29.7.2. Die obere Hälfte beantwortet die Frage nach den hohlen Images, die untere die nach der Geschwindigkeit. Nur die untere hat den Pin zurückgebracht.
| Messung | overlay2 (alter Treiber) | containerd-Store |
|---|---|---|
| Fehlerhafte Images, 5 Runden à 2,2 GB und 22 Schichten | 0 von 5 | 0 von 5 |
| overlayfs-Meldungen im Kernel-Log | keine | keine |
| Synthetischer Build, Gesamtzeit | 20 s | 36 s |
| Odoo-Build, kalt | 14 s | 37 s |
| davon der Export-Schritt | 3,5 s | 19,7 s |
Odoo-Build, warm nach docker system prune -f |
0–1 s | 35–36 s |
Der synthetische Durchlauf lief am 14.08.2026 mit Zufallsdaten, die sich nicht komprimieren lassen, und besteht damit fast vollständig aus dem Export-Schritt. Der Odoo-Build vom 15.08.2026 ist der echte Anwendungsfall: 394 Modul-Archive lagen vorab lokal, drei kalte und zwei warme Runden je Durchlauf.
Die letzte Zeile ist die, auf die es ankommt. Unser Update-Werkzeug ruft nach jedem Durchlauf docker system prune -f auf, und der Build-Cache des containerd-Stores übersteht das nicht — jedes Update wäre dort ein vollständiger Neubau.
Die Zahlen der vorletzten und drittletzten Zeile gehören zusammen gelesen: Hätten sich die Gesamtzeiten unterschieden, ohne dass der Export-Schritt sich mitbewegt, wäre der Speichertreiber nicht die Ursache gewesen. Er bewegt sich mit, um den Faktor 5,6.
Erstellt von Martin Schmid, mit Unterstützung von Claude Opus 5 und nach eigener inhaltlicher Prüfung freigegeben. Es gelten unsere Hinweise und Haftungsausschluss.