fix(mounts): DIE URSACHE gefunden - die CIFS-Verbindung stirbt mit dem Container
Ampel / ampel (push) Successful in 31s

Der Savepoint v3.17 fuehrte das als "Ursache liegt beim NAS, nicht gefunden".
Gemessen ist es etwas ganz anderes, und es liegt bei uns:

  /proc/fs/cifs/DebugData  ->  Net namespace: 4026532653
  api-Container            ->  net:[4026532653]   DIESELBE
  worker-Container         ->  net:[4026532540]   andere

Die CIFS-Verbindung lebt in der NETZ-NAMESPACE DES API-CONTAINERS - dort wird
sie eingehaengt, weil nur dieser Container CAP_SYS_ADMIN hat. Wird der Container
neu gebaut, stirbt sein Netz-Namespace und mit ihm der Socket. Der Mount steht
danach weiter in /proc/mounts (per rshared auf den Host propagiert) und sieht
vollkommen gesund aus - aber jeder Zugriff laeuft in den CIFS-Timeout.

Damit erklaert sich alles, was vorher widerspruechlich aussah: warum es nach
JEDEM Deploy passiert, warum `mount` Erfolg meldet, warum /proc/mounts genau eine
korrekte Schicht zeigt, und warum nur ein echtes Neu-Verbinden hilft. Das NAS ist
unschuldig (eine Sitzung, Status 1, 630 Credits, Ping 0,47 ms).

Zweiter Fund, der den Rest erklaert: Direkt nach einem frischen Mount antwortete
die Freigabe - und Sekunden spaeter nicht mehr. Das ist ein Wettlauf mit
`umount -l`: lazy heisst, der Abbau passiert spaeter, und faellt er samt
Propagation hinter den neuen Mount, zeigt der Pfad wieder auf die Leiche. Eine
einzige Probe kann das nicht sehen - deshalb prueft `wirklich_erreichbar()`
zweimal mit drei Sekunden Abstand, und zwar sowohl beim Mounten als auch in der
Wache.

Dazu: erste Pruefung der Wache schon nach 10 s statt 60 s. Genau dann ist die
Lage nach einem Deploy kaputt.

Ehrlich offen bleibt die strukturelle Folge: Der api-Container HAELT die
NAS-Verbindung. Startet er mitten in einem Rip neu, verliert auch der Worker sein
Ziel. Das saubere Gegenmittel waere ein Mount auf dem HOST statt im Container -
ein eigener Umbau, und er widerspraeche "Speicherziele ueber das UI einhaengen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 14:32:51 +02:00
parent 549727f648
commit 4cf7acbb96
3 changed files with 93 additions and 14 deletions
+27 -8
View File
@@ -381,24 +381,41 @@ ROHDATEN_INTERVALL_SEKUNDEN = 30
# später läuft jeder Zugriff in die Zeitgrenze. Derselbe Ablauf ein zweites Mal,
# ein bis zwei Minuten später, stellt sie zuverlässig her.
#
# Die Ursache liegt am NAS und ist nicht gefunden. Aber die Wirkung ist teuer:
# Nach JEDEM Update war jeder Rip auf die NAS kaputt, ohne dass irgendwo etwas
# davon zu sehen war. Wenn die Heilung bekannt und billig ist, gehört sie
# automatisiert — auch ohne die Ursache zu kennen. Genau das tut diese Schleife.
# ## DIE URSACHE, gemessen am 26.07.2026
#
# /proc/fs/cifs/DebugData → Net namespace: 4026532653
# api-Container → net:[4026532653] ← dieselbe
# worker-Container → net:[4026532540] ← andere
#
# Die CIFS-Verbindung lebt in der NETZ-NAMESPACE DES API-CONTAINERS — hier wird
# sie eingehängt (nur dieser Container hat CAP_SYS_ADMIN). Wird der Container neu
# gebaut, stirbt sein Netz-Namespace und damit der Socket. Der Mount steht danach
# weiter in /proc/mounts (er ist per rshared auf den Host propagiert) und sieht
# vollkommen gesund aus — aber jeder Zugriff läuft in den CIFS-Timeout.
#
# Deshalb passiert es nach JEDEM Deploy, deshalb sieht `mount` gesund aus, und
# deshalb hilft nur ein echtes Neu-Verbinden aus dem neuen Container heraus.
#
# Und deshalb ist der api-Container die einzige Stelle, die die NAS-Verbindung
# hält: Startet er mitten in einem Rip neu, verliert auch der Worker sein Ziel.
# Das strukturell zu lösen (Mount auf dem HOST statt im Container) wäre ein
# eigener Umbau und widerspräche „Speicherziele über das UI einhängen".
MOUNT_WACHE_INTERVALL_SEKUNDEN = 60
# Erste Prüfung schon nach zehn Sekunden: Genau dann ist die Lage nach einem
# Deploy kaputt, und ein Fenster von einer Minute wäre unnötig lang.
MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN = 10
_MOUNT_STAND = {}
async def _mount_schleife():
"""Sieht nach, ob die Freigaben antworten, und verbindet sie sonst neu."""
await asyncio.sleep(MOUNT_WACHE_ERSTE_PRUEFUNG_SEKUNDEN)
while True:
# Erst warten: Der Start hat gerade selbst gemountet (alle_remounten),
# und die zweite Chance soll die sein, die laut Messung funktioniert.
await asyncio.sleep(MOUNT_WACHE_INTERVALL_SEKUNDEN)
try:
await asyncio.to_thread(_mounts_nachsehen)
except Exception as e: # darf nie sterben
print(f"Mount-Wache fehlgeschlagen: {type(e).__name__}: {e}")
await asyncio.sleep(MOUNT_WACHE_INTERVALL_SEKUNDEN)
def _mounts_nachsehen() -> None:
@@ -413,7 +430,9 @@ def _mounts_nachsehen() -> None:
return
for eintrag in eintraege:
name = eintrag["name"]
erreichbar = mount_verwaltung.ist_erreichbar(name)
# Zweimal mit Abstand: Nach einem frischen Mount antwortet die Freigabe
# einen Moment und stirbt dann wieder (Wettlauf mit dem lazy umount).
erreichbar = mount_verwaltung.wirklich_erreichbar(name)
vorher = _MOUNT_STAND.get(name)
_MOUNT_STAND[name] = erreichbar
if erreichbar:
+31 -4
View File
@@ -67,6 +67,29 @@ def ist_erreichbar(name: str) -> bool:
return False
def wirklich_erreichbar(name: str, warten=None) -> bool:
"""Antwortet die Freigabe auch noch DREI SEKUNDEN später? (zweimal geprüft)
Warum zweimal (Befund 26.07.2026): Direkt nach einem frischen `mount`
antwortete die Freigabe reproduzierbar — und Sekunden später lief jeder
Zugriff in die Zeitgrenze. Ursache ist ein Wettlauf beim Aufräumen:
`_stale_mounts_loesen` benutzt `umount -l`, und das ist LAZY — es hängt den
Mount sofort aus der Sicht aus, der eigentliche Abbau passiert später. Fällt
dieser Abbau samt Propagation (rshared) hinter den neuen Mount, zeigt der
Pfad wieder auf die Leiche.
Eine einzige Probe kann das nicht sehen. Zwei mit Abstand schon.
"""
if warten is None:
import time
warten = time.sleep
if not ist_erreichbar(name):
return False
warten(3)
return ist_erreichbar(name)
def schreibtest(pfad: str) -> bool:
"""Berechtigungs-Prüfung: können wir im Ziel wirklich schreiben?
@@ -323,14 +346,18 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
"NAS meist ablehnen."
)
raise RuntimeError(f"mount schlug fehl: {fehler[:300]}{hinweis}")
if ist_erreichbar(name):
# Zweimal mit Abstand prüfen — eine einzige Probe direkt nach dem
# Mount sieht den Wettlauf mit dem lazy umount nicht (siehe
# wirklich_erreichbar).
if wirklich_erreichbar(name):
return schreibtest(ziel)
if versuch == 1:
_lazy_umount(ziel)
raise RuntimeError(
f"{quelle} wurde eingehängt, antwortet aber nicht (zwei Versuche). "
"Läuft die Freigabe? Bei einem NAS im Ruhezustand hilft meist ein "
"erneutes Einhängen über Einstellungen → Speicherziele → Reparieren."
f"{quelle} wurde eingehängt, antwortet aber nicht dauerhaft (zwei "
"Versuche). Läuft die Freigabe? Bei einem NAS im Ruhezustand hilft "
"meist ein erneutes Einhängen über Einstellungen → Speicherziele → "
"Reparieren."
)
finally:
if creds_datei:
+35 -2
View File
@@ -183,9 +183,11 @@ def test_mounten_geht_bei_totem_mount_den_reparatur_weg(monkeypatch):
import mounts
ablauf = []
antworten = iter([False, True]) # vorher tot, nach dem Mount erreichbar
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: next(antworten))
# Vorher tot (der Vor-Check), nach dem Mount dauerhaft erreichbar. Die
# Doppelprobe wird hier gestubbt, damit der Test nicht 3 s echt wartet.
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: True)
monkeypatch.setattr(mounts, "_stale_mounts_loesen",
lambda ziel: ablauf.append("loesen"))
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
@@ -216,6 +218,7 @@ def test_mounten_prueft_das_ergebnis_und_versucht_es_zweimal(monkeypatch):
# nie erreichbar: vorher, nach Versuch 1, nach Versuch 2
monkeypatch.setattr(mounts.os, "makedirs", lambda *a, **k: None)
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "wirklich_erreichbar", lambda name: False)
monkeypatch.setattr(mounts, "_stale_mounts_loesen", lambda ziel: None)
monkeypatch.setattr(mounts, "_lazy_umount", lambda ziel: ablauf.append("lazy"))
monkeypatch.setattr(mounts, "schreibtest", lambda p: True)
@@ -248,3 +251,33 @@ def test_mounten_laesst_gesunden_mount_in_ruhe(monkeypatch):
lambda ziel: (_ for _ in ()).throw(AssertionError("nicht loesen!")))
assert mounts.mounten("rippy", "cifs", "//nas/rippy") is True
def test_wirklich_erreichbar_prueft_zweimal_mit_abstand(monkeypatch):
"""Befund 26.07.2026: Direkt nach einem frischen `mount` antwortete die
Freigabe - und Sekunden spaeter lief jeder Zugriff in die Zeitgrenze
(Wettlauf mit dem lazy umount, dessen Abbau hinter den neuen Mount fiel).
Eine EINZIGE Probe kann das nicht sehen."""
import mounts
antworten = iter([True, False]) # erst ja, dann nein
gewartet = []
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: next(antworten))
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
assert gewartet == [3]
def test_wirklich_erreichbar_bei_gesunder_freigabe(monkeypatch):
import mounts
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: True)
assert mounts.wirklich_erreichbar("rippy", warten=lambda s: None) is True
def test_wirklich_erreichbar_spart_das_warten_wenn_schon_die_erste_probe_faellt(monkeypatch):
import mounts
gewartet = []
monkeypatch.setattr(mounts, "ist_erreichbar", lambda name: False)
assert mounts.wirklich_erreichbar("rippy", warten=gewartet.append) is False
assert gewartet == [] # nicht drei Sekunden fuer nichts