diff --git a/docker/api/main.py b/docker/api/main.py index 731f0b0..72f6765 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -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: diff --git a/docker/api/mounts.py b/docker/api/mounts.py index d641fb2..92ea8ca 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -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: diff --git a/docker/api/test_mounts_helpers.py b/docker/api/test_mounts_helpers.py index 6c58cb2..61ade25 100644 --- a/docker/api/test_mounts_helpers.py +++ b/docker/api/test_mounts_helpers.py @@ -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