
🌐 Auch auf: English · Français · Español
Deep Dive aus der Reihe rund um die Ansible-Tour. Niveau: fortgeschrittene Linux-Administration. Die Tour erklärt die Rollen; die Deep Dives erklären die Technik darunter – wie sie funktioniert, warum sie hier ist, was genau dieses Setup damit macht, wie man sie im Alltag betreibt und was es sonst noch gegeben hätte.
Jedes Image in diesem Setup ist auf eine exakte Version gepinnt. Das ist gut für die Reproduzierbarkeit und in einer bestimmten Hinsicht schlecht für die Sicherheit: Ein gepinntes Image wird von allein nicht besser. Die Debian- oder Alpine-Pakete darin altern, die Go-Version, mit der ein Binary gebaut wurde, altert, und jeden Tag werden neue Lücken für Software veröffentlicht, die sich gar nicht verändert hat. Der Host bekommt seine Sicherheitsupdates jede Nacht von dnf-automatic; die Images bekamen nichts – bis jemand hinsah. Trivy sieht jetzt jede Nacht hin. Dieser Beitrag erklärt, wie es Schwachstellen findet, wie es seit dem 19. Oktober 2026 auf dem Blog läuft, was der erste Scan fand – 241 behebbare Befunde – und warum diese Zahl viel weniger beunruhigend ist, als sie klingt, sobald man sie sortiert.
🧭 Wie Trivy funktioniert
Erst eine Inventur, dann ein Abgleich mit der Datenbank
Trivy führt nichts aus und greift nichts an. Es liest ein Image Schicht für Schicht und erstellt eine Inventur: welches Betriebssystem in welcher Version (Debian 13, Alpine 3.21, Ubuntu 22.04 …), welche Pakete in welchen exakten Versionen laut Datenbank des Paketmanagers, und welche Abhängigkeiten in Binaries einkompiliert oder in Lock-Dateien aufgeführt sind – Go-Module aus den Build-Informationen, die jedes Go-Binary mitträgt, npm, pip, Composer, Java und so weiter. Dann vergleicht es diese Inventur mit seiner Schwachstellen-Datenbank.
Die Datenbank baut das Trivy-Projekt aus vielen Quellen – den Security-Trackern der Distributionen, der GitHub Advisory Database, der Go-Schwachstellen-Datenbank und weiteren – und verteilt sie als eine Datei von einigen hundert Megabyte. Für jedes Paket weiß sie, welche Versionen von welcher CVE betroffen sind und, falls es ihn gibt, in welcher Version der Fehler behoben ist.
Schweregrad und „behoben in“ – die zwei Felder, auf die es ankommt
- Schweregrad (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN) stammt aus der Quelle – oft die eigene Bewertung der Distribution, sonst ein CVSS-Wert. Er beschreibt die Lücke allgemein, nicht ihr Risiko in diesem Image. Ein CRITICAL-Fehler in einer Bibliothek, die das Image nie aufruft, steht trotzdem als CRITICAL da.
- Behoben in (Fixed Version) sagt, ob es überhaupt einen Fix gibt. Ohne ihn bleibt nur, den Befund zu akzeptieren oder die Software zu ersetzen. Mit ihm lautet die Frage: Wer muss ihn ausliefern – die Distribution (schon geschehen), der Image-Maintainer (Neubau nötig) oder das Upstream-Projekt (neues Release nötig)?
Diese Unterscheidung ist der Schlüssel zu allem, was folgt. Trivy meldet, was bekannt ist; ob der verwundbare Code überhaupt erreichbar ist, kann es nicht wissen. Die Ergebnisse zu lesen ist deshalb immer ein zweiter Schritt, den ein Mensch macht.
Woher das Image kommt
Trivy kann ein Image aus einer Registry lesen (remote), aus einem lokalen Docker- oder Podman-Daemon, aus einer tar-Datei – oder direkt aus containerd, der Runtime, die K3s nutzt. Die letzte Variante heißt: kein zweiter Download, der Scan sieht genau das Image, das die Pods ausführen. Hier braucht es dafür den K3s-Socket und den Namespace:
CONTAINERD_ADDRESS=/run/k3s/containerd/containerd.sock \
CONTAINERD_NAMESPACE=k8s.io \
trivy image --image-src containerd docker.io/library/mariadb:10.11.19🛡️ Warum es hier ist
- Gepinnte Images altern still. Der Image-Refresh in den Update-Playbooks holt neu gebaute Images unter demselben Tag – aber nichts sagte uns, wann sich ein Neubau lohnte oder wann ein Image nie einen bekommen würde.
- Der Host war abgedeckt, die Container nicht. dnf-automatic patcht den Host jede Nacht; die 14 Images, aus denen der Cluster besteht, lagen außerhalb dieses Kreislaufs.
- Entscheidungen brauchen Daten. „Sollen wir diesen Exporter wechseln?“ oder „Ist ein K3s-Update dringend?“ ist mit einer Liste dessen, was jedes Image tatsächlich mitbringt, leichter zu beantworten.
🔧 Was dieses Repository macht
Ein Timer auf dem Host, kein Operator im Cluster
Der übliche Weg, Trivy in Kubernetes zu betreiben, ist der Trivy Operator: Er beobachtet den Cluster, startet pro Workload einen Scan-Job und speichert die Ergebnisse als Custom Resources. Auf einem einzelnen kleinen Knoten hieße das CRDs, ein Controller, Scan-Pods – und für jeden davon Ausnahmen in der Image-Policy und den NetworkPolicies, die dieses Setup durchsetzt. Die Rolle common_trivy erledigt dieselbe Aufgabe mit einem Binary und einem Skript: Ein systemd-Timer startet jede Nacht um 05:15 trivy_scan.py, nach den Sicherheitsupdates (01:00–02:00), dem Neustart-Fenster (03:30) und den anderen Nachtjobs.
Das Binary: gepinnt und mit Prüfsumme
Trivy wird aus dem GitHub-Release-Archiv installiert, mit Version und SHA-256 gepinnt in versions.yml. Der zweite Pin hat einen konkreten Grund: Im März 2026 war die Release-Pipeline von Trivy selbst kompromittiert und lieferte kurzzeitig einen bösartigen Build aus (Advisory GHSA-69fq-xp46-6×23). Ein Sicherheitsscanner läuft als root und liest alles – genau das Werkzeug, bei dem man sicher sein will. Die Regel in den Notizen: nur auf ein Release aktualisieren, das mindestens zwei Wochen alt ist, und die Prüfsumme aus dessen Checksum-Datei übernehmen. Ansibles get_url verweigert ein Archiv, dessen Hash nicht passt.
Zwei Kleinigkeiten, auf die die Installation stieß, beide an anderer Stelle gewollt: /tmp ist durch die CIS-Härtung noexec eingehängt, daher wird das Archiv aus /root entpackt; und nach dem Entpacken gibt restorecon dem Binary sein richtiges SELinux-Label – Dateien, die über einen temporären Pfad geschrieben werden, behalten sonst dessen Label, dieselbe Lehre wie beim k3s-Binary.
Das Skript
- Die Schwachstellen-Datenbank einmal aktualisieren (
--download-db-only); alle Image-Scans laufen danach mit--skip-db-update. - Die Images aller laufenden Pods auflisten. Beendete Pods von Jobs und CronJobs bleiben draußen – die erste Version nahm sie mit, und ein helm-install-Job, der einmal beim Clusterstart lief, steuerte 32 Befunde für ein Image bei, das gar nicht läuft.
- Jedes Image aus containerd scannen. Schlägt das fehl, ein zweiter Versuch aus der Registry (siehe unten). Ein JSON-Bericht pro Image nach
/var/lib/trivy/reports. - Metriken für den Textfile-Collector des node_exporter schreiben:
trivy_image_vulnerabilities{image, severity, fixable},trivy_image_scan_ok,trivy_db_update_ok,trivy_last_run_timestamp_seconds. Die Datei wird unter einem temporären Namen geschrieben und umbenannt, damit node_exporter nie eine halbe liest. - Die behebbaren CRITICAL- und HIGH-Befunde mit der Nacht davor vergleichen und nur die neuen mailen.
Warum eine Mail für neue Befunde und kein Alarm
Das naheliegende Design wäre ein Grafana-Alarm „kritische Lücke mit verfügbarem Fix“. Der erste Scan zeigte, warum das falsch wäre: Das MariaDB-Image enthält gosu, ein winziges Hilfsprogramm in Go, gebaut mit einer älteren Go-Version, deren Standardbibliothek eine CRITICAL-Lücke hat. Beheben müssen das die Maintainer des MariaDB-Images. Ein Alarm würde feuern, sich alle vier Stunden wiederholen und das tage- oder wochenlang tun – bis er ignoriert wird, und mit ihm jeder andere Alarm. Die Mail sagt es einmal. Ein Befund, der bleibt, mailt nicht noch einmal; einer, der verschwindet, wird als „weg“ gezählt. Grafana alarmiert nur, wenn der Scan selbst kaputt ist: Datenbank-Update oder ein Image-Scan fehlgeschlagen, oder der letzte erfolgreiche Lauf ist älter als 36 Stunden.
Einen Befund akzeptieren
Manche Befunde werden bewusst akzeptiert. Sie kommen in die Variable trivy_ignore, jeder mit einer Pflicht-Begründung, und landen in einer .trivyignore-Datei, die der Scan nutzt. Die Liste ist absichtlich leer: Jeder Eintrag ist eine Entscheidung, kein Standard.
SELinux und der Textfile-Collector
node_exporter läuft seit demselben Tag in einer eigenen SELinux-Domain (nach einer Permissive-Lernphase seit dem 20./21. Oktober im Enforcing-Modus). Sein Textfile-Verzeichnis /var/lib/node_exporter/textfile bekam einen eigenen Typ, homelab_node_exporter_textfile_t, mit genau zwei Regeln: Der Exporter darf das Verzeichnis auflisten und die Dateien lesen. Der Scan schreibt sie als root; neue Dateien erben den Typ des Verzeichnisses.
🔍 Was die ersten Läufe fanden – über das Werkzeug selbst
Layer, die containerd schon weggeworfen hatte
Zwei von 15 Images scheiterten im ersten Lauf: metrics-server und kube-state-metrics, mit „content digest … not found“. containerd entpackt Image-Layer in Snapshots und darf die komprimierten Layer-Blobs danach verwerfen; das Image läuft weiter, lässt sich aber nicht mehr als Ganzes exportieren. Trivy bietet --image-src containerd,remote als Rückfall-Liste an – was nicht half, weil es nur zurückfällt, wenn das Image in der ersten Quelle fehlt, nicht wenn ein Layer fehlt. Das Skript versucht es jetzt für genau diese Images ausdrücklich noch einmal mit remote.
Ein AVC, gefunden von den Log-Alarmen desselben Tages
Die erste Version der Scan-Unit hatte NoNewPrivileges=yes. Das Skript ruft k3s kubectl auf, um die Pods aufzulisten, und das k3s-Binary wechselt in die SELinux-Domain container_runtime_t. Unter NoNewPrivileges verweigert SELinux diesen Wechsel, wenn die Policy ihn nicht ausdrücklich erlaubt, und protokolliert avc: denied { nnp_transition }. Der neue Loki-Alarm für echte SELinux-Verweigerungen feuerte innerhalb von Minuten. Die Einstellung ist entfernt; die Unit behält die übrigen systemd-Einschränkungen (/usr und /etc nur lesbar, keine Home-Verzeichnisse, eigenes /tmp, niedrigste CPU- und I/O-Priorität, 2 GB Speichergrenze).
📊 241 Befunde lesen
Nach den Korrekturen meldete der Scan 209 behebbare CRITICAL/HIGH-Befunde über 14 laufende Images. Sortiert danach, wer sie beheben kann, ändert sich das Bild völlig:
| Gruppe | Befunde | Wer kann es beheben | Aktion |
|---|---|---|---|
| php-fpm_exporter 2.2.0 | 72 (5 der 6 CRITICAL) | Niemand: letztes Release 2022, noch mit OpenSSL 1.1 | Ersetzen, neu bauen oder entfernen – eine Entscheidung, siehe unten |
| MariaDB 10.11.19 | 26 | Image-Maintainer: Ein Neubau unter demselben Tag ist schon veröffentlicht (libssl3) | Image-Refresh im Update-Playbook; gosu bleibt, bis upstream es neu baut |
| WordPress 7.1.3 (php8.4-fpm) | 32 | Debian hat curl, ghostscript, expat behoben; das offizielle Image wurde seitdem nicht neu gebaut | Abwarten – der Image-Refresh nimmt den nächsten Neubau automatisch mit |
| nginx, Redis (Alpine) | 4 | Ebenso: Fixes in Alpine, Image noch nicht neu gebaut | Abwarten |
| Go-Binaries: CoreDNS, metrics-server, cert-manager, kube-state-metrics, mysqld_exporter | 75 | Upstream-Projekte: neue Releases mit neuerem Go und neueren Modulen | Schon auf den neuesten Releases; die mit K3s gebündelten kommen mit dem nächsten K3s |
Drei Lehren aus dieser Tabelle:
- Die meisten Befunde verlangen nichts von uns. Sie sind irgendwo upstream behoben und kommen mit dem nächsten Neubau oder Release, das die Update-Playbooks ohnehin abholen.
- Ein Image ist ein echtes Problem. Ein Exporter ohne Release seit vier Jahren, der noch OpenSSL 1.1 mitbringt, ist für fast ein Drittel aller Befunde und fast alle CRITICAL verantwortlich. Seine tatsächliche Angriffsfläche ist klein – er spricht nur FastCGI mit PHP-FPM im selben Pod und liefert Metriken innerhalb des Clusters, hinter NetworkPolicies –, aber „kleine Angriffsfläche“ ist kein Grund, verwaiste Software ewig zu behalten.
- „Behoben“ heißt nicht „relevant“. Das WordPress-Image führt
linux-libc-devauf, die Kernel-Header – ein Container nutzt den Kernel des Hosts, die Header sind nur zum Kompilieren da. Ein Kandidat fürtrivy_ignore, mit genau dieser Begründung.
Eine Frage kam sofort: Gibt es ein neueres WordPress-Image, das seine 32 Befunde behebt? Nein. WordPress 7.1.3 ist das aktuelle Release, und das Image 7.1.3-php8.4-fpm auf dem Blog hat denselben Digest wie das in der Registry. Debian hat die korrigierten Pakete veröffentlicht, aber das offizielle Image wurde seitdem nicht neu gebaut. Ein eigenes Image mit zusätzlichem apt-get upgrade würde es heute beheben – zum Preis, für immer einen Image-Build pflegen zu müssen. Ein paar Tage auf den offiziellen Neubau zu warten ist der bessere Tausch.
🧰 Betrieb im Alltag
# wann lief es, wie lange, wie viel Speicher?
systemctl list-timers trivy-scan.timer
journalctl -u trivy-scan --since today
# jetzt laufen lassen (oneshot, kehrt zurück, wenn fertig)
systemctl start trivy-scan.service
# die Zusammenfassung des letzten Laufs
journalctl -u trivy-scan -n 20 | grep "images scanned"
# welche Befunde gerade offen sind (Image, CVE, Paket, installiert -> behoben)
python3 -c 'import json; [print(v["severity"], v["id"], v["pkg"], v["installed"], "->", v["fixed"], v["image"]) for v in json.load(open("/var/lib/trivy/last-findings.json")).values()]' | sort | less
# ein Image im Detail
trivy image --image-src containerd --skip-db-update docker.io/library/redis:7.4.11-alpineIn Grafana (Explore → Prometheus) sind dieselben Zahlen abfragbar:
# behebbare CRITICAL pro Image
trivy_image_vulnerabilities{severity="CRITICAL", fixable="true"} > 0
# behebbare HIGH+CRITICAL insgesamt über die Zeit - sinkt es nach einem Update?
sum(trivy_image_vulnerabilities{severity=~"CRITICAL|HIGH", fixable="true"})
# Alter des letzten Scans in Stunden
(time() - trivy_last_run_timestamp_seconds) / 3600Was tun mit einer Mail
- Ist das Image upstream unter demselben Tag neu gebaut? Dann behebt es der nächste Update-Lauf (Image-Refresh) – oder man startet das Update-Playbook gleich.
- Gibt es ein neueres Release? Dann den Pin in
versions.ymlanheben, am besten auf eines, das ein paar Tage alt ist. - Weder noch? Dann ist es ein Befund zum Akzeptieren (mit Begründung in
trivy_ignore) oder ein Grund, die Software zu ersetzen.
Kosten
Schwachstellen-Datenbank und Layer-Cache belegen etwa 1,6 GB unter /var/cache/trivy. Ein vollständiger nächtlicher Lauf samt Datenbank-Download braucht auf dem Blog unter einer halben Minute CPU; spätere Läufe nutzen die zwischengespeicherte Layer-Analyse und brauchen ein paar Sekunden. Der Scan läuft mit niedrigster CPU- und I/O-Priorität und 2 GB Speichergrenze, er kann die Seiten also nicht ausbremsen.
🆘 Spickzettel: Symptom → Befehl
Die Kurzfassung dieses Artikels für den Moment, in dem etwas nicht stimmt. Auf dem Host als root ausführen; <…> sind Platzhalter.
| Symptom | Befehl |
|---|---|
| Lief der Scan? | systemctl list-timers trivy-scan.timer |
| Jetzt ausführen | systemctl start trivy-scan.service |
| Was ist gerade offen? | python3 -c 'import json; [print(v["severity"], v["id"], v["pkg"], v["image"]) for v in json.load(open("/var/lib/trivy/last-findings.json")).values()]' | sort |
| Ein Image im Detail | trivy image --image-src containerd --skip-db-update <image>:<tag> |
| Eine Mail zu einem CRITICAL-Fund | # 1. rebuilt upstream under the same tag? -> next update run |
| Scan-Ergebnisse zu alt | (time() - trivy_last_run_timestamp_seconds) / 3600 |
| Platz, den der Cache belegt | du -sh /var/cache/trivy |
🔭 Was als Nächstes kommt
- Eine Entscheidung zum PHP-FPM-Exporter bis zum 20. Januar 2027, wenn seine Akzeptanz ausläuft: Zwei Forks einzelner Entwickler sind aktiv (einer mit automatischen Abhängigkeits-Updates); sonst die Akzeptanz verlängern.
- Konfigurations-Scans – im Oktober 2026 einmal gegen die laufenden Manifeste des Blogs ausprobiert: 21 Arten von Befunden, fast alles bewusste Entscheidungen (Ingress im Host-Netz, hostPath-Volumes, Images, die als root laufen). Als CI-Job bräuchte das gerenderte Templates und eine lange Ausnahmeliste; das lohnt sich vorerst nicht.
- Secret-Scans laufen schon in der CI: gitleaks prüft bei jedem Push die ganze Git-Historie, neben dem Pre-Commit-Hook.
- Ein kleines Dashboard mit dem Verlauf der offenen Befunde pro Image.
⚖️ Alternativen – und warum Trivy
| Option | Stärke | Warum nicht hier |
|---|---|---|
| Trivy Operator | Laufend, Ergebnisse als Kubernetes-Ressourcen | Controller, CRDs und Scan-Pods auf einem einzelnen kleinen Knoten; Ausnahmen in Image-Policy und NetworkPolicies. |
| Grype (Anchore) | Schnell, gute SBOM-Integration mit Syft | Vergleichbar; Trivy liest containerd direkt und deckt in einem Binary mehr Ökosysteme ab. |
| Registry-Scans (Harbor/Clair, Docker Scout, GitHub) | Scannt beim Push | Hier gibt es keine eigene Registry – die Images kommen so, wie sie veröffentlicht sind, von Docker Hub, Quay und registry.k8s.io. |
| dnf updateinfo / OpenSCAP | Genau für den Host | Deckt nur die Host-Pakete ab, nicht den Inhalt der Images. |
| Trivy als nächtlicher Host-Timer | Ein Binary, liest die laufenden Images aus containerd, Metriken + Mail | Gewählt. |
Der Wert des Werkzeugs liegt nicht in der Zahl, die es meldet, sondern in der Sortierung, die es möglich macht. 241 Befunde klangen nach einem Notfall. Sortiert danach, wer sie beheben kann, wurden daraus eine echte Entscheidung, ein Image-Refresh – und viel Warten auf upstream, das die bestehende Update-Routine schon erledigt.


