Zum Inhalt springen
Daniel Gietmann
← Blog
Coding

Alte Docker-Tags automatisch aufräumen, ohne das Falsche zu löschen

docker-cleanup behält in einer eigenen Docker-Registry pro Repository die neuesten Tags und löscht den Rest nach Zeitplan. Wie er sortiert, warum ein Löschen mehr Tags treffen kann als gedacht und warum danach noch kein Platz frei ist.

Titelbild: Alte Docker-Tags automatisch aufräumen, ohne das Falsche zu löschen

Jeder CI-Lauf, der ein Image in eine eigene Registry schiebt, legt dort einen neuen Tag an. Nach ein paar Monaten liegen viele Versionen herum, und gebraucht werden meist nur die letzten paar. Von selbst räumt die Registry nichts davon weg.

Deshalb habe ich docker-cleanup geschrieben. Das Tool spricht die Registry über die HTTP-API v2 an, die Docker- und OCI-Registries gemeinsam haben. Pro Repository behält es die neuesten Tags und löscht den Rest nach einem Zeitplan. Es läuft als Container, und konfiguriert wird es nur über Umgebungsvariablen.

Zuerst ein Probelauf

docker run --rm \
  -e REGISTRY_URL=https://registry.example.com \
  -e REGISTRY_USERNAME=user \
  -e REGISTRY_PASSWORD=pass \
  -e RETENTION_COUNT=10 \
  -e IGNORE_TAG_PATTERNS='latest,stable' \
  -e RUN_ONCE=true \
  ghcr.io/danielgtmn/docker-cleanup:latest

DELETE_ENABLED steht standardmäßig auf false. Dann schreibt der Container für jeden Tag, den er löschen würde, eine Zeile Dry-run: would delete tag manifest mit Repository, Tag und Digest ins Log. Pro Repository kommt eine Zusammenfassung dazu: wie viele Tags es gibt, wie viele davon bleiben und wie viele weg sollen. Mit RUN_ONCE=true beendet sich der Container nach einem Durchlauf.

Statt Benutzername und Passwort kannst du auch ein Token über REGISTRY_TOKEN mitgeben. Wenn die Registry ihre Tokens von einem eigenen Token-Server holen lässt, antwortet sie erst mit 401. Der Container holt sich das Token dann selbst und meldet sich dort mit Benutzername und Passwort an.

Schau dir die Liste aus dem Probelauf in Ruhe an. Mit DELETE_ENABLED=true verschwinden genau diese Tags, und dazu jeder andere Tag, der auf denselben Digest zeigt. Warum, steht weiter unten.

Wie der Container sortiert

Pro Repository holt der Container alle Tags und liest zu jedem das Manifest. Bei Multi-Arch-Images nimmt er das Manifest für linux/amd64, und wenn es das nicht gibt, das erste in der Liste. Das Alter liest er aus dem Feld created im Config-Blob des Images.

Tags, die auf ein Muster in IGNORE_TAG_PATTERNS passen, bleiben immer stehen und zählen nicht mit. Von den übrigen bleiben die RETENTION_COUNT neuesten. Findet der Container zu einem Tag kein Manifest, lässt er ihn ebenfalls stehen und schreibt eine Warnung ins Log. Die Muster sind Globs, durch Kommas getrennt. release-* schützt zum Beispiel alle Tags, die mit release- anfangen. Mit IGNORE_REPOSITORY_PATTERNS nimmst du ganze Repositories aus.

Wie gut das klappt, hängt am Datum. Manche Build-Werkzeuge schreiben für reproduzierbare Builds ein festes Datum in created, etwa den 1. Januar 1970. Haben alle Images dasselbe Datum, sagt die Sortierung nichts mehr darüber, welcher Tag neu ist. Tags ganz ohne Datum landen hinter allen Tags mit Datum, fliegen also zuerst raus. Untereinander sortiert der Container sie nach dem Namen, und zwar aufsteigend als Text. Der alphabetisch erste Name gilt dann als der neueste. Bei v1, v2, v9 und v10 und RETENTION_COUNT=2 bleiben v1 und v10 stehen, v2 und v9 werden gelöscht.

Gelöscht wird über den Digest

docker-cleanup löscht das Manifest, auf das ein Tag zeigt, und schickt dafür dessen Digest an die Registry. Zeigen mehrere Tags auf dasselbe Image, sind danach alle weg. In der Referenz-Registry distribution sucht der Handler fürs Löschen zuerst alle Tags zu diesem Digest heraus und entfernt dann jeden davon.

Ein Muster in IGNORE_TAG_PATTERNS schützt deshalb nur den Namen des Tags. Ein Beispiel: stable steht in IGNORE_TAG_PATTERNS und zeigt auf dasselbe Image wie v1.4.0, das schon aus den zehn neuesten herausgefallen ist. Der Container löscht das Manifest von v1.4.0, und stable ist danach auch weg. Dasselbe passiert mit einem Tag, der eigentlich unter den neuesten bleiben sollte, wenn er sich den Digest mit einem gelöschten Tag teilt. docker-cleanup prüft das im Moment nicht.

Setzt deine CI latest immer zusammen mit dem neuesten Release, passiert nichts, weil dieses Release ohnehin bleibt. Aufpassen musst du, wenn du ein älteres Image nachträglich mit einem geschützten Tag versiehst. Den Digest eines Tags zeigt dir zum Beispiel:

docker buildx imagetools inspect registry.example.com/my/app:stable

Taucht derselbe Digest im Probelauf bei einem Tag auf, der gelöscht werden soll, ist stable mit betroffen.

Die Registry muss Löschen erlauben

distribution lehnt das Löschen über die API standardmäßig ab. Annehmen tut sie es erst mit diesem Eintrag in der Konfiguration:

storage:
  delete:
    enabled: true

Als Umgebungsvariable heißt das REGISTRY_STORAGE_DELETE_ENABLED=true. Fehlt die Einstellung, antwortet die Registry mit 405, und im Log des Containers steht für jeden Tag ein Delete failed mit diesem Status.

Ist das Manifest weg, liegen die Layer trotzdem noch auf der Platte. Den Speicher gibt erst die Garbage Collection der Registry frei. Bei distribution ist das ein eigener Befehl:

registry garbage-collect --dry-run /pfad/zur/config.yml

Mit --dry-run zeigt er nur an, was er entfernen würde, ohne die Option löscht er wirklich. Laut Dokumentation soll die Registry währenddessen nur lesbar sein oder gar nicht laufen, weil ein Upload während der Garbage Collection sonst Layer verlieren kann. Die Garbage Collection gehört deshalb getrennt vom Aufräumen in ein Zeitfenster, in dem niemand Images hochlädt. Harbor, GitLab und andere Registries haben dafür eigene Wege.

Zeitplan und Neustarts

Ohne RUN_ONCE läuft der Container dauerhaft und räumt nach CRON_EXPRESSION auf, standardmäßig 0 3 * * *. Die Uhrzeit gilt in der Zeitzone des Containers, und ohne TZ ist das UTC. Mit TZ=Europe/Berlin ist 3 Uhr auch 3 Uhr deutscher Zeit.

Gleich beim Start läuft außerdem ein Durchgang, bevor der Zeitplan überhaupt greift. Mit DELETE_ENABLED=true löscht also jeder Neustart und jedes Deployment sofort und wartet nicht bis zur Nacht. Solange du noch an der Konfiguration schraubst, lass DELETE_ENABLED besser auf false.

Ohne REPOSITORIES holt sich der Container die Liste aller Repositories über /v2/_catalog. Nicht jede Registry gibt diesen Endpunkt frei. Antwortet sie mit 403 oder 404, bricht der Lauf mit einem Hinweis ab. Dann trägst du die Repositories selbst ein, zum Beispiel REPOSITORIES=my/api,my/worker.

Wenn der Probelauf passt, sieht das in Docker Compose etwa so aus:

services:
  registry-cleanup:
    image: ghcr.io/danielgtmn/docker-cleanup:latest
    environment:
      - REGISTRY_URL=https://registry.example.com
      - REGISTRY_USERNAME=user
      - REGISTRY_PASSWORD=pass
      - RETENTION_COUNT=10
      - IGNORE_TAG_PATTERNS=latest,stable
      - DELETE_ENABLED=true
      - CRON_EXPRESSION=0 4 * * *
      - TZ=Europe/Berlin
    restart: unless-stopped