v0.8.12-beta - image-controle van twaalf minuten naar seconden
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m41s
Deploy server-up (prod) / deploy (push) Successful in 13m14s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
This commit is contained in:
Ramon 2026-08-04 07:49:52 +02:00
parent 930dce351b
commit 331475415a
5 changed files with 255 additions and 18 deletions

View file

@ -99,9 +99,16 @@ jobs:
# ging, en wezen Forgejo, Planka en Baby Buddy naar tags die er niet # ging, en wezen Forgejo, Planka en Baby Buddy naar tags die er niet
# zijn. Draait bewust rustig: registries knijpen af, en een afgeknepen # zijn. Draait bewust rustig: registries knijpen af, en een afgeknepen
# verzoek is niet te onderscheiden van een ontbrekend image. # 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 \ docker run --rm \
-v "$PWD:/w" -w /w \ -v "$PWD:/w" -w /w \
-v su-image-cache:/cache \
-e PYTHONDONTWRITEBYTECODE=1 \ -e PYTHONDONTWRITEBYTECODE=1 \
-e SU_IMAGE_CACHE=/cache/images.json \
python:3.12-slim \ python:3.12-slim \
python tools/controleer_images.py python tools/controleer_images.py

View file

@ -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 # v0.8.11-beta — Overnemen en verwijderen
Handmatig gestarte apps stonden in de lijst, maar je kon er alleen mee starten, Handmatig gestarte apps stonden in de lijst, maar je kon er alleen mee starten,

View file

@ -1 +1 @@
0.8.11-beta 0.8.12-beta

112
tests/test_image_cache.py Normal file
View file

@ -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

View file

@ -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 Planka en Baby Buddy naar tags te wijzen die er niet zijn. Zulke fouten merk je
anders pas als iemand op installeren drukt. 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 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 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 je bijna een werkend sjabloon gaat "repareren". Dus weinig tegelijk, en wat niet
niet meteen lukt gaat opnieuw met oplopende pauzes. 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 from __future__ import annotations
import concurrent.futures as cf import concurrent.futures as cf
import hashlib
import json import json
import os
import re import re
import sys import sys
import threading
import time import time
from collections import defaultdict
from pathlib import Path from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent WORTEL = Path(__file__).resolve().parent.parent
@ -27,10 +48,25 @@ sys.path.insert(0, str(WORTEL / "server-up" / "core"))
import registry # noqa: E402 import registry # noqa: E402
TEGELIJK = 4 TEGELIJK_PER_REGISTRY = 4
POGINGEN = 3 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) 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]]: def images() -> dict[str, list[str]]:
"""Elk image in de catalogus, met de apps die het gebruiken.""" """Elk image in de catalogus, met de apps die het gebruiken."""
@ -44,7 +80,42 @@ def images() -> dict[str, list[str]]:
return uit 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: def bestaat(image: str) -> bool:
host = registry.parse_image(image)[0] or "?"
with _slot(host):
for poging in range(POGINGEN): for poging in range(POGINGEN):
try: try:
if registry.manifest_digest(image): if registry.manifest_digest(image):
@ -58,14 +129,31 @@ def bestaat(image: str) -> bool:
def main() -> int: def main() -> int:
alles = images() alles = images()
with cf.ThreadPoolExecutor(TEGELIJK) as ex: nu = time.time()
uitslag = dict(zip(alles, ex.map(bestaat, alles))) 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} fout = {i: alles[i] for i, ok in uitslag.items() if not ok}
if "--json" in sys.argv: 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: 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()): for image, apps in sorted(fout.items()):
print(f"{image} ({', '.join(sorted(set(apps)))})") print(f"{image} ({', '.join(sorted(set(apps)))})")
if not fout: if not fout: