From 331475415a16e464dc97471952700cecf5a1911c Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 4 Aug 2026 07:49:52 +0200 Subject: [PATCH] v0.8.12-beta - image-controle van twaalf minuten naar seconden Gemeten: 709 seconden voor 318 images, terwijl de netwerktijd zo'n 48 seconden is. Twee oorzaken. - het aantal tegelijk gold in totaal in plaats van per registry. Registries knijpen per host af en tweederde staat op Docker Hub, dus ghcr, lscr en quay stonden daarachter te wachten zonder dat dat iets oploste. Nu vier per registry, registries naast elkaar - elke ronde controleerde alles opnieuw. Geslaagde controles blijven een week staan, met spreiding per image zodat niet alles op dezelfde dag verloopt. De sleutel is de naam met tag, dus een gewijzigde tag wordt altijd gecontroleerd; mislukkingen komen nooit in de cache Gemeten na de wijziging: koude cache 43s, warme cache 0s. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy --- .forgejo/workflows/deploy.yml | 7 ++ CHANGELOG.md | 30 +++++++++ VERSION | 2 +- tests/test_image_cache.py | 112 +++++++++++++++++++++++++++++++ tools/controleer_images.py | 122 +++++++++++++++++++++++++++++----- 5 files changed, 255 insertions(+), 18 deletions(-) create mode 100644 tests/test_image_cache.py diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 8febe9c..1a73071 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -99,9 +99,16 @@ jobs: # ging, en wezen Forgejo, Planka en Baby Buddy naar tags die er niet # zijn. Draait bewust rustig: registries knijpen af, en een afgeknepen # verzoek is niet te onderscheiden van een ontbrekend image. + # + # Geslaagde controles blijven een week staan in su-image-cache, met + # spreiding zodat niet alles op dezelfde dag verloopt. Een sjabloon + # dat naar een andere tag gaat wijzen levert een nieuwe sleutel op en + # wordt dus altijd gecontroleerd; mislukkingen komen nooit in de cache. docker run --rm \ -v "$PWD:/w" -w /w \ + -v su-image-cache:/cache \ -e PYTHONDONTWRITEBYTECODE=1 \ + -e SU_IMAGE_CACHE=/cache/images.json \ python:3.12-slim \ python tools/controleer_images.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e51d27..127f4d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +# v0.8.12-beta — De image-controle van twaalf minuten naar seconden + +De controle of elk image nog bestaat kostte elf à twaalf minuten, bij élke push, +ook als er geen enkel sjabloon was gewijzigd. Gemeten: 709 seconden, terwijl de +netwerktijd voor 318 images maar zo'n 48 seconden is. + +Twee dingen zaten fout. + +**Het aantal tegelijk gold in totaal in plaats van per registry.** Registries +knijpen per host af, en tweederde van de catalogus staat op Docker Hub. Met vier +tegelijk in totaal stonden ghcr, lscr en quay dus achter Docker Hub in de rij te +wachten zonder dat dat iets oploste. Nu geldt de limiet van vier per registry en +lopen registries naast elkaar. + +**Elke ronde controleerde alles opnieuw.** Geslaagde controles blijven nu een +week staan, met een vaste spreiding per image zodat niet de hele catalogus op +dezelfde dag verloopt. De sleutel is de volledige naam mét tag: een sjabloon dat +naar een andere tag gaat wijzen levert een nieuwe sleutel op en wordt dus altijd +gecontroleerd. Mislukkingen komen er nooit in — een ontbrekend image blijft elke +ronde gemeld tot het is opgelost, want daar is deze controle voor. + +Gemeten na de wijziging: + +``` +koude cache 318 gecontroleerd 43 s +warme cache 0 gecontroleerd 0 s +``` + +Met `--alles` negeer je de cache en wordt alles opnieuw nagelopen. + # v0.8.11-beta — Overnemen en verwijderen Handmatig gestarte apps stonden in de lijst, maar je kon er alleen mee starten, diff --git a/VERSION b/VERSION index 6b5ca58..4dc5aa8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.11-beta +0.8.12-beta diff --git a/tests/test_image_cache.py b/tests/test_image_cache.py new file mode 100644 index 0000000..7d71f52 --- /dev/null +++ b/tests/test_image_cache.py @@ -0,0 +1,112 @@ +"""De cache van de image-controle. + +De volledige ronde kost elf à twaalf minuten omdat registries afknijpen en een +afgeknepen verzoek niet te onderscheiden is van een ontbrekend image. Die ronde +draaide bij élke push, ook als er geen enkel sjabloon was gewijzigd. + +Wat hier vastligt is niet de snelheid maar de correctheid van het overslaan: een +gewijzigde tag mag nooit ongezien blijven, en een ontbrekend image mag nooit uit +beeld verdwijnen doordat het ooit goed was. +""" +import importlib +import json +import sys +import time +from pathlib import Path + +import pytest + +WORTEL = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def ci(tmp_path, monkeypatch): + monkeypatch.setenv("SU_IMAGE_CACHE", str(tmp_path / "images.json")) + sys.path.insert(0, str(WORTEL / "tools")) + for naam in [n for n in sys.modules if n == "controleer_images"]: + del sys.modules[naam] + mod = importlib.import_module("controleer_images") + return mod + + +def test_een_verse_cache_controleert_alles(ci): + alles = {"nginx:alpine": ["a"], "redis:7": ["b"]} + assert sorted(ci.te_controleren(alles, {}, time.time())) == sorted(alles) + + +def test_recent_goed_bevonden_wordt_overgeslagen(ci): + nu = time.time() + alles = {"nginx:alpine": ["a"], "redis:7": ["b"]} + cache = {"nginx:alpine": nu, "redis:7": nu} + assert ci.te_controleren(alles, cache, nu) == [] + + +def test_een_andere_tag_wordt_altijd_gecontroleerd(ci): + """De sleutel is de naam mét tag. Wijzigt een sjabloon zijn tag, dan is dat + een nieuwe sleutel — anders zou je juist de wijziging ongezien laten.""" + nu = time.time() + cache = {"nginx:alpine": nu} + assert ci.te_controleren({"nginx:1.27": ["a"]}, cache, nu) == ["nginx:1.27"] + + +def test_de_cache_verloopt(ci): + nu = time.time() + oud = nu - ci.HOUDBAAR - ci.SPREIDING - 10 + assert ci.te_controleren({"nginx:alpine": ["a"]}, {"nginx:alpine": oud}, nu) \ + == ["nginx:alpine"] + + +def test_niet_alles_verloopt_op_dezelfde_dag(ci): + """Zonder spreiding heb je na een week alsnog één ronde van twaalf minuten.""" + ts = 1_700_000_000.0 + momenten = {ci.houdbaar_tot(f"voorbeeld/app-{i}:latest", ts) for i in range(200)} + spanne = max(momenten) - min(momenten) + assert spanne > 2 * 24 * 3600, f"vervaldata liggen te dicht op elkaar: {spanne}s" + + +def test_een_ontbrekend_image_blijft_gemeld(ci, monkeypatch, tmp_path): + """Een mislukking mag nooit in de cache belanden: dan zou hij één keer + gemeld worden en daarna een week onzichtbaar zijn.""" + monkeypatch.setattr(ci, "images", + lambda: {"bestaat/niet:1": ["proefapp"], "goed/wel:1": ["b"]}) + monkeypatch.setattr(ci, "bestaat", lambda i: not i.startswith("bestaat/niet")) + + assert ci.main() == 1 # eerste ronde meldt hem + cache = json.loads((tmp_path / "images.json").read_text()) + assert "goed/wel:1" in cache + assert "bestaat/niet:1" not in cache, "de mislukking is onthouden" + + assert ci.main() == 1, "tweede ronde zwijgt over het ontbrekende image" + + +def test_verdwenen_images_blijven_niet_in_de_cache_hangen(ci, monkeypatch, tmp_path): + (tmp_path / "images.json").write_text(json.dumps({ + "oud/weg:1": time.time(), "blijft/staan:1": time.time()})) + monkeypatch.setattr(ci, "images", lambda: {"blijft/staan:1": ["a"]}) + monkeypatch.setattr(ci, "bestaat", lambda i: True) + ci.main() + cache = json.loads((tmp_path / "images.json").read_text()) + assert list(cache) == ["blijft/staan:1"], cache + + +def test_alles_negeert_de_cache(ci, monkeypatch, tmp_path): + (tmp_path / "images.json").write_text(json.dumps({"nginx:alpine": time.time()})) + gecontroleerd = [] + monkeypatch.setattr(ci, "images", lambda: {"nginx:alpine": ["a"]}) + monkeypatch.setattr(ci, "bestaat", + lambda i: (gecontroleerd.append(i), True)[1]) + monkeypatch.setattr(sys, "argv", ["x", "--alles"]) + ci.main() + assert gecontroleerd == ["nginx:alpine"] + + +def test_zonder_cachepad_werkt_het_gewoon(tmp_path, monkeypatch): + """Lokaal draaien zonder volume mag niet stukgaan.""" + monkeypatch.delenv("SU_IMAGE_CACHE", raising=False) + sys.path.insert(0, str(WORTEL / "tools")) + for naam in [n for n in sys.modules if n == "controleer_images"]: + del sys.modules[naam] + mod = importlib.import_module("controleer_images") + assert mod.CACHE_PAD is None + assert mod.lees_cache() == {} + mod.schrijf_cache({"a": 1}) # mag geen fout geven diff --git a/tools/controleer_images.py b/tools/controleer_images.py index 0ad1cb2..5c3e50b 100644 --- a/tools/controleer_images.py +++ b/tools/controleer_images.py @@ -5,21 +5,42 @@ offline was gehaald en het image niet meer bestond. Daarna bleken ook Forgejo, Planka en Baby Buddy naar tags te wijzen die er niet zijn. Zulke fouten merk je anders pas als iemand op installeren drukt. - python3 tools/controleer_images.py [--json] + python3 tools/controleer_images.py [--json] [--alles] -**Waarom dit langzaam gaat.** Registries knijpen af bij te veel verzoeken achter +**Waarom dit langzaam wás.** Registries knijpen af bij te veel verzoeken achter elkaar, en een afgeknepen verzoek ziet er precies zo uit als een image dat niet bestaat. Eén ronde met zestien tegelijk leverde negen valse meldingen op — waarop -je bijna een werkend sjabloon gaat "repareren". Daarom weinig tegelijk, en wat -niet meteen lukt gaat opnieuw met oplopende pauzes. +je bijna een werkend sjabloon gaat "repareren". Dus weinig tegelijk, en wat niet +meteen lukt gaat opnieuw met oplopende pauzes. Bij 318 images kostte dat elf à +twaalf minuten, élke keer, ook als er geen enkel sjabloon was gewijzigd. + +Drie dingen brengen dat terug: + +**Geslaagde controles gaan in een cache.** Een image dat gisteren bestond bestaat +vandaag vrijwel zeker nog. De sleutel is de volledige naam mét tag, dus een +sjabloon dat naar een andere tag gaat wijzen levert vanzelf een nieuwe sleutel op +en wordt gewoon gecontroleerd. Wat je aanraakt wordt dus altijd geverifieerd. + +**Mislukkingen komen er nooit in.** Een ontbrekend image blijft elke ronde +gemeld tot het is opgelost — precies waarvoor deze controle bestaat. + +**Het aantal tegelijk geldt per registry.** Het afknijpen gebeurt per host, en +tweederde van de catalogus staat op Docker Hub; de andere registries hoeven daar +niet achter in de rij te staan. + +Met `--alles` negeer je de cache en wordt alles opnieuw gecontroleerd. """ from __future__ import annotations import concurrent.futures as cf +import hashlib import json +import os import re import sys +import threading import time +from collections import defaultdict from pathlib import Path WORTEL = Path(__file__).resolve().parent.parent @@ -27,10 +48,25 @@ sys.path.insert(0, str(WORTEL / "server-up" / "core")) import registry # noqa: E402 -TEGELIJK = 4 +TEGELIJK_PER_REGISTRY = 4 POGINGEN = 3 +HOUDBAAR = 7 * 24 * 3600 +# Zonder spreiding verloopt de hele catalogus op dezelfde dag, en heb je alsnog +# één ronde van twaalf minuten. +SPREIDING = 3 * 24 * 3600 IMAGE_RE = re.compile(r"^\s*image:\s*(\S+)\s*$", re.M) +CACHE_PAD = Path(os.environ["SU_IMAGE_CACHE"]) if os.environ.get( + "SU_IMAGE_CACHE") else None + +_sloten: dict[str, threading.Semaphore] = {} +_sloten_lock = threading.Lock() + + +def _slot(host: str) -> threading.Semaphore: + with _sloten_lock: + return _sloten.setdefault(host, threading.Semaphore(TEGELIJK_PER_REGISTRY)) + def images() -> dict[str, list[str]]: """Elk image in de catalogus, met de apps die het gebruiken.""" @@ -44,28 +80,80 @@ def images() -> dict[str, list[str]]: return uit +def houdbaar_tot(image: str, ts: float) -> float: + """Vervaldatum, met een vaste spreiding per image.""" + h = int(hashlib.sha256(image.encode()).hexdigest()[:8], 16) + return ts + HOUDBAAR + (h % SPREIDING) + + +def lees_cache() -> dict: + if not CACHE_PAD or not CACHE_PAD.is_file(): + return {} + try: + d = json.loads(CACHE_PAD.read_text(encoding="utf-8")) + return d if isinstance(d, dict) else {} + except Exception: + return {} + + +def schrijf_cache(cache: dict) -> None: + if not CACHE_PAD: + return + try: + CACHE_PAD.parent.mkdir(parents=True, exist_ok=True) + CACHE_PAD.write_text(json.dumps(cache, indent=1), encoding="utf-8") + except OSError as e: + print(f" (cache niet opgeslagen: {e})") + + +def te_controleren(alles, cache: dict, nu: float) -> list[str]: + """Wat is er niet recent al goed bevonden?""" + return [i for i in alles + if not isinstance(cache.get(i), (int, float)) + or houdbaar_tot(i, cache[i]) < nu] + + def bestaat(image: str) -> bool: - for poging in range(POGINGEN): - try: - if registry.manifest_digest(image): - return True - except Exception: - pass - if poging < POGINGEN - 1: - time.sleep(2 ** poging) # 1s, 2s + host = registry.parse_image(image)[0] or "?" + with _slot(host): + for poging in range(POGINGEN): + try: + if registry.manifest_digest(image): + return True + except Exception: + pass + if poging < POGINGEN - 1: + time.sleep(2 ** poging) # 1s, 2s return False def main() -> int: alles = images() - with cf.ThreadPoolExecutor(TEGELIJK) as ex: - uitslag = dict(zip(alles, ex.map(bestaat, alles))) + nu = time.time() + cache = {} if "--alles" in sys.argv else lees_cache() + te_doen = te_controleren(alles, cache, nu) + uit_cache = len(alles) - len(te_doen) + + uitslag: dict[str, bool] = {} + if te_doen: + with cf.ThreadPoolExecutor(min(16, len(te_doen))) as ex: + uitslag = dict(zip(te_doen, ex.map(bestaat, te_doen))) + + for image, ok in uitslag.items(): + if ok: + cache[image] = nu + else: + cache.pop(image, None) + # Images die niet meer in de catalogus staan hoeven we niet te bewaren. + schrijf_cache({k: v for k, v in cache.items() if k in alles}) fout = {i: alles[i] for i, ok in uitslag.items() if not ok} if "--json" in sys.argv: - print(json.dumps({"gecontroleerd": len(alles), "fout": fout}, indent=2)) + print(json.dumps({"gecontroleerd": len(te_doen), "uit_cache": uit_cache, + "fout": fout}, indent=2)) else: - print(f"{len(alles)} unieke images gecontroleerd") + print(f"{len(alles)} unieke images: {len(te_doen)} gecontroleerd, " + f"{uit_cache} recent al goed bevonden") for image, apps in sorted(fout.items()): print(f" ✖ {image} ({', '.join(sorted(set(apps)))})") if not fout: