fix(mounts): DIE URSACHE gefunden - die CIFS-Verbindung stirbt mit dem Container
Ampel / ampel (push) Successful in 31s
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:
+27
-8
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user