Leeres Repository nach Loeschen des letzten Tags entfernen

Die Registry-API loescht nur Manifeste/Tags, das Repository-Verzeichnis
bleibt im Speicher und damit im Katalog ("keine Tags") stehen. Da der
Auth-Container den Registry-Speicher gemountet hat, wird das leere
Verzeichnis jetzt entfernt:
- beim Loeschen des letzten Tags direkt
- bei der Garbage Collection werden zusaetzlich alle Tag-losen
  Repositories aufgeraeumt (fuer bereits vorhandene Reste)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:10:27 +02:00
parent b273098b50
commit 4a11aabdf3
2 changed files with 63 additions and 4 deletions
+60 -2
View File
@@ -1,6 +1,7 @@
import os import os
import re import re
import secrets import secrets
import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
from functools import wraps from functools import wraps
@@ -26,6 +27,7 @@ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "changeme")
DB_PATH = os.environ.get("DB_PATH", "/data/users.db") DB_PATH = os.environ.get("DB_PATH", "/data/users.db")
HTPASSWD_PATH = os.environ.get("HTPASSWD_PATH", "/auth/htpasswd") HTPASSWD_PATH = os.environ.get("HTPASSWD_PATH", "/auth/htpasswd")
GC_CONFIG = os.environ.get("GC_CONFIG", "/app/registry-gc-config.yml") GC_CONFIG = os.environ.get("GC_CONFIG", "/app/registry-gc-config.yml")
REGISTRY_DATA = os.environ.get("REGISTRY_DATA", "/var/lib/registry")
REGISTRY_URL = os.environ.get("REGISTRY_URL", "http://registry:5000") REGISTRY_URL = os.environ.get("REGISTRY_URL", "http://registry:5000")
# Host:Port ohne Schema, fuer skopeo-Ziel (z. B. "registry:5000") # Host:Port ohne Schema, fuer skopeo-Ziel (z. B. "registry:5000")
REGISTRY_HOST = REGISTRY_URL.split("://", 1)[-1] REGISTRY_HOST = REGISTRY_URL.split("://", 1)[-1]
@@ -213,6 +215,51 @@ def delete_manifest(name, digest):
return False return False
def repo_has_tags(name):
"""True, wenn das Repository noch mindestens einen Tag hat."""
data = query_registry(f"/v2/{name}/tags/list")
return bool(data and data.get("tags"))
def remove_repository_storage(name):
"""Entfernt das leere Repository-Verzeichnis aus dem Registry-Speicher.
Die Registry-API kennt kein "Repository loeschen"; nach dem Loeschen des
letzten Tags bleibt sonst ein leerer Eintrag im Katalog stehen.
"""
base = os.path.realpath(
os.path.join(REGISTRY_DATA, "docker", "registry", "v2", "repositories")
)
target = os.path.realpath(os.path.join(base, *name.split("/")))
# Sicherheit: Ziel muss innerhalb des repositories-Verzeichnisses liegen
if target != base and not target.startswith(base + os.sep):
return False
if not os.path.isdir(target):
return False
shutil.rmtree(target, ignore_errors=True)
# Leere Eltern-Verzeichnisse (z. B. "bitnami" nach "bitnami/redis") aufraeumen
parent = os.path.dirname(target)
while parent != base and os.path.isdir(parent) and not os.listdir(parent):
try:
os.rmdir(parent)
except OSError:
break
parent = os.path.dirname(parent)
return True
def prune_empty_repositories():
"""Entfernt alle Repositories ohne Tags aus dem Speicher. Gibt Anzahl zurueck."""
data = query_registry("/v2/_catalog")
if not data:
return 0
removed = 0
for name in data.get("repositories", []):
if not repo_has_tags(name) and remove_repository_storage(name):
removed += 1
return removed
def remote_digest(source, src_creds=None, src_token=None): def remote_digest(source, src_creds=None, src_token=None):
"""Liefert den Manifest-Digest eines Quell-Images, ohne es herunterzuladen.""" """Liefert den Manifest-Digest eines Quell-Images, ohne es herunterzuladen."""
cmd = ["skopeo", "inspect", "--format", "{{.Digest}}", f"docker://{source}"] cmd = ["skopeo", "inspect", "--format", "{{.Digest}}", f"docker://{source}"]
@@ -439,7 +486,16 @@ def delete_image():
return redirect(url_for("images")) return redirect(url_for("images"))
if delete_manifest(name, digest): if delete_manifest(name, digest):
flash(f'Image "{name}:{tag}" wurde geloescht.', "success") # War das der letzte Tag? Dann das leere Repository entfernen, damit
# es nicht weiter (ohne Tags) im Katalog stehen bleibt.
if not repo_has_tags(name) and remove_repository_storage(name):
flash(
f'Image "{name}:{tag}" geloescht - Repository "{name}" entfernt '
"(keine Tags mehr).",
"success",
)
else:
flash(f'Image "{name}:{tag}" wurde geloescht.', "success")
else: else:
flash(f'Image "{name}:{tag}" konnte nicht geloescht werden.', "error") flash(f'Image "{name}:{tag}" konnte nicht geloescht werden.', "error")
@@ -465,9 +521,11 @@ def garbage_collect():
for line in result.stdout.splitlines() for line in result.stdout.splitlines()
if line.startswith("blob eligible for deletion") if line.startswith("blob eligible for deletion")
) )
pruned = prune_empty_repositories()
extra = f" {pruned} leere(s) Repository entfernt." if pruned else ""
flash( flash(
"Garbage Collection abgeschlossen. " "Garbage Collection abgeschlossen. "
f"{freed} nicht mehr benoetigte Blob(s) wurden entfernt.", f"{freed} nicht mehr benoetigte Blob(s) wurden entfernt.{extra}",
"success", "success",
) )
else: else:
+3 -2
View File
@@ -188,8 +188,9 @@
<p style="margin-top: 0.6rem; font-size: 0.85rem; color: #6b7280; line-height: 1.6;"> <p style="margin-top: 0.6rem; font-size: 0.85rem; color: #6b7280; line-height: 1.6;">
Beim Loeschen eines Images verschwindet der Tag sofort, der belegte Speicherplatz wird Beim Loeschen eines Images verschwindet der Tag sofort, der belegte Speicherplatz wird
aber erst hierdurch freigegeben. Entfernt alle Daten, die von keinem Image mehr aber erst hierdurch freigegeben. Entfernt alle Daten, die von keinem Image mehr
referenziert werden. <strong>Wichtig:</strong> waehrend der Aufraeumung keine Images referenziert werden, sowie leere Repositories ohne Tags.
hochladen, sonst koennen frisch hochgeladene Daten verloren gehen. <strong>Wichtig:</strong> waehrend der Aufraeumung keine Images hochladen, sonst
koennen frisch hochgeladene Daten verloren gehen.
</p> </p>
</div> </div>
</div> </div>