perf(build): image bouwt niet meer alles opnieuw bij elke uitgave
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m51s

ARG SU_VERSION stond bovenaan met een LABEL erachter. Die waarde verandert
per release, dus alle lagen erna vervielen: apt-get, de Docker-CLI van 70 MB
en de front-end-bestanden. Versie en label staan nu onderaan, en de assets
vóór de broncode zodat ze niet bij elke commit opnieuw van het CDN komen.

Ook: een mislukte pull noemt nu de waarschijnlijke oorzaak (geen gepusht
image) in plaats van alleen naar toegangsrechten te wijzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
This commit is contained in:
Ramon 2026-08-07 16:05:46 +02:00
parent f86d7d35a6
commit 14515143fc
5 changed files with 164 additions and 16 deletions

View file

@ -1,7 +1,23 @@
# v0.8.31-beta — In ontwikkeling
# v0.8.31-beta — Bouwen duurt geen kwartier meer
Nog niets uitgebracht sinds v0.8.30. Wat hier onder komt te staan gaat mee in
de volgende uitgave; bij het uitbrengen wordt deze kop de releaseversie.
De volgorde in de Dockerfile bepaalt hoeveel er bij elke build opnieuw moet.
`ARG SU_VERSION` stond met een LABEL erachter bovenaan, en die waarde verandert
per uitgave — dus élke release gooide alles daarna weg: `apt-get`, de
Docker-CLI van 70 MB, de front-end-bestanden van het CDN. Er veranderden drie
regels Python en de machine bouwde het hele image opnieuw.
- **Versie en label staan onderaan.** Alles erboven blijft in de cache; een
uitgave bouwt alleen nog de laatste lagen.
- **De front-end-bestanden staan vóór de broncode.** Hun versies liggen vast,
dus ze worden niet meer bij elke commit opnieuw van het CDN gehaald — en de
push uploadt alleen nog de laag die echt veranderd is.
- **Een test bewaakt die volgorde**, want dit is precies het soort ding dat
ongemerkt terugsluipt bij de volgende wijziging.
- **Duidelijker melding als het image ontbreekt.** Een Forgejo-release ontstaat
bij de git-tag, maar het image komt uit de build — en die pusht alleen als de
deploy weet naar welke registry. Dan zie je een release die je niet kunt
installeren, en "heeft deze server toegang?" is dan een misleidend antwoord.
Draait je installatie op een zelfgebouwd image, dan zegt de melding dat ook.
# v0.8.30 — Je ziet nu waarom een app niet start

View file

@ -4,13 +4,16 @@ COPY server-up/requirements.txt .
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
FROM python:3.12-slim
# Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
# docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
# Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
ARG SU_VERSION=dev
LABEL org.opencontainers.image.title="Server Up" \
org.opencontainers.image.version="${SU_VERSION}" \
org.opencontainers.image.source="https://git.example.com/bes-r/server-up"
# De volgorde hieronder is met opzet: alles wat zelden verandert staat boven,
# alles wat bij elke commit verandert onderaan. Docker hergebruikt een laag
# alleen zolang niets ervóór is gewijzigd, dus één regel op de verkeerde plek
# kost bij elke build opnieuw de hele rit.
#
# Wat hier eerder misging: `ARG SU_VERSION` met een LABEL erachter stond
# bovenaan. Die waarde verandert per release, dus elke uitgave gooide álle
# lagen erna weg — apt-get, de Docker-CLI van 70 MB, de front-end-bestanden,
# alles. Vandaar dat versie en label nu helemaal onderaan staan.
# util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker
# afzakt zonder de capabilities te verliezen die Server Up nodig heeft. Het is
# een Essential-pakket en dus al aanwezig, maar expliciet noemen zorgt dat een
@ -40,9 +43,11 @@ RUN DPKG_ARCH=$(dpkg --print-architecture) \
COPY --from=build /inst /usr/local
WORKDIR /app
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
# De front-end-bestanden staan vóór de broncode: hun versies staan hier vast,
# dus deze lagen blijven staan zolang je ze niet zelf wijzigt. Stonden ze erna,
# dan haalde elke commit ze opnieuw van het CDN én werden ze bij elke push
# opnieuw geüpload.
RUN mkdir -p static/fonts \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
@ -70,7 +75,6 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
# eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
SU_VERSION=${SU_VERSION} \
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
@ -83,3 +87,22 @@ HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://l
# SU_UID:SU_GID. Zonder die variabelen verandert er niets: dan blijft het root.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python","app.py"]
# ── Vanaf hier verandert er iets bij elke commit ─────────────────────────────
# Alles hierboven blijft in de cache staan; alleen deze laatste lagen worden
# opnieuw gebouwd. Daardoor duurt een release een halve minuut in plaats van
# een kwartier, en hoeft de push alleen deze lagen te uploaden.
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
# Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
# docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
# Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
# Helemaal onderaan, want deze waarde verandert per uitgave en zou alles
# eronder ongeldig maken.
ARG SU_VERSION=dev
ENV SU_VERSION=${SU_VERSION}
LABEL org.opencontainers.image.title="Server Up" \
org.opencontainers.image.version="${SU_VERSION}" \
org.opencontainers.image.source="https://git.example.com/bes-r/server-up"

View file

@ -186,8 +186,22 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
log_fn(f"Ophalen {image}")
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
if rc != 0:
return False, (f"Ophalen van {image} mislukt. Bestaat die tag in de "
"registry, en heeft deze server er toegang toe?")
# "Not found" is bijna nooit een toegangsprobleem: er staat gewoon geen
# image met die tag. De release bestaat wel — die maakt Forgejo bij de
# git-tag — maar de build pusht alleen als de deploy-omgeving weet naar
# welke registry hij moet. Draait deze installatie bovendien op een
# zelfgebouwd image, dan valt er sowieso niets op te halen.
lokaal = inspect_self().get("image", "")
extra = ""
if lokaal and "/" not in lokaal.rsplit(":", 1)[0]:
extra = (f" Deze installatie draait '{lokaal}', een lokaal gebouwd "
"image; bijwerken gaat dan via install.sh en niet via deze "
"knop.")
return False, (
f"Ophalen van {image} mislukt. Er is een release met deze versie, "
"maar geen image met deze tag in de registry — controleer of de "
"build hem gepusht heeft (SU_IMAGE in de .env van je deploy) en of "
"deze server bij de registry mag." + extra)
return True, image

59
tests/test_dockerfile.py Normal file
View file

@ -0,0 +1,59 @@
"""De volgorde van de lagen in de Dockerfile bepaalt hoe lang een build duurt.
Docker hergebruikt een laag alleen zolang niets ervóór is gewijzigd. Stond het
versienummer bovenaan, dan gooide élke uitgave alles daarna weg: apt-get, de
Docker-CLI van 70 MB, de front-end-bestanden. Een release duurde daardoor een
kwartier terwijl er drie regels Python waren veranderd.
"""
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
REGELS = (WORTEL / "Dockerfile").read_text(encoding="utf-8").splitlines()
def _regel_van(fragment: str) -> int:
"""Het regelnummer van de eerste regel die dit fragment bevat."""
for i, r in enumerate(REGELS):
if fragment in r and not r.lstrip().startswith("#"):
return i
raise AssertionError(f"niet gevonden in de Dockerfile: {fragment}")
def test_de_broncode_komt_na_alles_wat_zelden_verandert():
"""De code wijzigt bij elke commit; wat daarvoor staat blijft in de cache."""
code = _regel_van("COPY server-up/ ./")
for eerder in ("apt-get update",
"download.docker.com",
"COPY --from=build /inst",
"static/fonts",
"static/vendor",
"mkdir -p /data/stacks"):
assert _regel_van(eerder) < code, \
f"'{eerder}' hoort vóór de broncode te staan, anders vervalt zijn cache"
def test_het_versienummer_staat_helemaal_onderaan():
"""SU_VERSION verandert per uitgave. Alles wat erná komt wordt opnieuw
gebouwd, dus er hoort niets meer achter te staan dan het label zelf."""
versie = _regel_van("ARG SU_VERSION")
for later in ("apt-get update", "download.docker.com", "static/vendor",
"COPY server-up/ ./", "ENTRYPOINT"):
assert _regel_van(later) < versie, \
f"'{later}' staat ná ARG SU_VERSION en wordt dus elke release herbouwd"
def test_de_frontendbestanden_worden_niet_door_de_broncode_overschreven():
"""Ze komen nu vóór `COPY server-up/`. Dat mag alleen als daar geen eigen
static-map in zit, want anders zou die de gedownloade bestanden vervangen."""
assert not (WORTEL / "server-up" / "static").exists(), (
"server-up/static bestaat nu wél; zet de downloads dan weer ná de COPY "
"of geef ze een andere plek")
def test_de_gepinde_versies_staan_er_nog():
"""De cache blijft alleen staan zolang deze regels niet wijzigen — dus als
ze veranderen hoort dat een bewuste keuze te zijn."""
tekst = "\n".join(REGELS)
for gepind in ("@mdi/font@7.4.47", "alpinejs@3.14.1", "htmx.org@2.0.3",
"tailwindcss.com/3.4.16", "docker-27.5.1.tgz", "compose/releases/download/v2.32.4"):
assert gepind in tekst, f"versie niet meer vastgezet: {gepind}"

View file

@ -117,3 +117,39 @@ def test_image_zonder_schema_blijft_gelijk(env):
# Een tag hoort er niet in; die komt van de release.
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up:0.8.28"})
assert selfupdate.image_repo({}) == "git.voorbeeld.nl/bes-r/server-up"
# ── Als het image er niet is ─────────────────────────────────────────────────
def test_mislukte_pull_noemt_de_waarschijnlijke_oorzaak(env, monkeypatch):
"""Een Forgejo-release ontstaat bij de git-tag, maar het image komt uit de
build en die pusht alleen als de deploy weet naar welke registry. Dan zie
je een release die je niet kunt installeren, en 'geen toegang' is dan een
misleidend antwoord."""
from core import selfupdate, docker
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
monkeypatch.setattr(selfupdate, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(selfupdate, "inspect_self",
lambda: {"image": "git.voorbeeld.nl/bes-r/server-up:0.8.29"})
monkeypatch.setattr(docker, "_stream", lambda *a, **k: 1)
ok, msg = selfupdate.pull("v0.8.30")
assert ok is False
assert "geen image met deze tag" in msg
assert "SU_IMAGE" in msg
def test_lokaal_gebouwd_image_wordt_als_zodanig_gemeld(env, monkeypatch):
"""Wie zijn image zelf bouwt kan met deze knop nooit iets ophalen; dat hoort
de melding te zeggen in plaats van naar de registry te wijzen."""
from core import selfupdate, docker
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
monkeypatch.setattr(selfupdate, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(selfupdate, "inspect_self",
lambda: {"image": "server-up:latest"})
monkeypatch.setattr(docker, "_stream", lambda *a, **k: 1)
ok, msg = selfupdate.pull("v0.8.30")
assert ok is False
assert "lokaal gebouwd image" in msg
assert "install.sh" in msg