Kai Ole Hartwig
11 Min. Lesezeit
Niedrig

Anwendungscode als Image-Volume statt oras-init

Für alle, die PHP-Anwendungen auf Kubernetes betreiben und den Code getrennt vom Runtime-Image als OCI-Artefakt ausliefern. In Code als OCI-Artefakt und in Teil 3 der Grundschutz-Serie holt ein Init-Container diesen Code bei jedem Pod-Start mit ORAS, und dort stand auch, dass ein Image-Volume unter containerd nicht funktioniert. Das stimmt inzwischen nicht mehr. Das Muster ist für TYPO3 entstanden, gilt aber genauso für Sylius und Laravel, und diese Feldnotiz beschreibt den Umstieg für alle drei, einschließlich des ersten Versuchs, der ohne jede Fehlermeldung ein leeres Verzeichnis lieferte.

01 — Die Ausgangslage

Alle Anwendungen laufen auf einem gemeinsamen, gehärteten Runtime-Image mit PHP und FrankenPHP. Der Code jeder Anwendung kommt getrennt davon als signiertes OCI-Artefakt aus der Registry: ein Tar mit vendor/, Konfiguration und öffentlichem Verzeichnis, bei einer TYPO3-Installation rund 500 MB groß. Die Pipeline baut es für TYPO3, Sylius und Laravel nach demselben Schema, nur die Liste der Pfade im Tar unterscheidet sich.

Im Pod übernahm bisher ein Init-Container die Auslieferung. Er zog das Artefakt mit oras pull, entpackte es in ein emptyDir und legte die Verzeichnisse an, in die das Framework zur Laufzeit schreibt. Das funktioniert zuverlässig, kostet aber bei jedem Pod-Start dieselbe Arbeit: Beim Pilot-Mandanten entfielen 13 von 53 Sekunden Kaltstart auf den Download. Dazu kamen ein eigenes Werkzeug im Pod, eine Retry-Schleife und ein Satz Zugangsdaten für die Registry.

Am deutlichsten wurde der Aufwand beim Scheduler, einem CronJob, der alle fünf Minuten startet. Damit er nicht jeden Tick ein halbes Gigabyte lädt, bekam er einen Code-Cache auf einem ReadWriteOnce-Volume und eine Marker-Datei je Release. Das sparte den Download, band den Scheduler aber an den Node, auf dem das Volume zuerst angelegt worden war.

02 — Image-Volumes und der erste Versuch

Ein Image-Volume hängt den Inhalt eines OCI-Images schreibgeschützt in einen Pod ein, ohne dass daraus ein Container wird. Das Feature kam mit Kubernetes 1.31 als Alpha, ist seit 1.35 standardmäßig aktiv und seit 1.36 stabil. Auf der Seite der Container-Runtime unterstützt containerd es ab Version 2.1, und schlanke Distributionen wie k3s liefern inzwischen eine passende Version mit.

 

volumes:
  - name: code
    image:
      reference: registry.example.com/shop/sources:2.38.15@sha256:…
      pullPolicy: IfNotPresent

 

Naheliegend war also, das vorhandene Artefakt direkt so anzugeben. Der kubelet zog es ohne Murren, 509 MB in knapp sieben Sekunden, und der Pod startete. Das Verzeichnis war allerdings leer, und weder im Event-Log noch im kubelet fand sich ein Hinweis darauf.

Die Ursache steckt im Manifest. Ein Artefakt nach den OCI-Richtlinien für Nicht-Images trägt einen eigenen artifactType, eine leere Config und Schichten mit eigenem Medientyp. containerd hängt aber nur ein, was wie ein Image aussieht, also eine Image-Config, deren rootfs.diff_ids die Schichten beschreiben. Alles andere wird gezogen, gespeichert und dann still ignoriert.

 

{
  "artifactType": "application/vnd.example.sources.v1",
  "config": { "mediaType": "application/vnd.oci.empty.v1+json" },
  "layers": [ { "mediaType": "application/vnd.example.sources.tar" } ]
}

 

Nebenbei zeigte derselbe Test eine zweite Hürde. Das Volume zieht der kubelet selbst, und er verwendet dafür die imagePullSecrets des Pods oder die Zugangsdaten des Nodes. Das Registry-Konto des Nodes darf die Quellprojekte der Mandanten aber bewusst nicht lesen, der erste Pull scheiterte deshalb mit insufficient_scope. Seitdem steht der Deploy-Token des Mandanten, den bisher der Init-Container nutzte, als Pull-Secret am Pod.

03 — Dieselbe Schicht, ein anderes Manifest

Die Lösung lag in der Schicht selbst. Das Tar ist unkomprimiert und enthält ausschließlich relative Pfade, es ist damit bereits eine gültige Image-Schicht vom Typ application/vnd.oci.image.layer.v1.tar. Weil es unkomprimiert ist, stimmt seine diff_id, also der Hash des entpackten Inhalts, mit dem Digest der Schicht überein. Es fehlte nur eine Image-Config, die das sagt, und ein Manifest, das auf beides zeigt.

Für den Test habe ich genau diese beiden Dateien hochgeladen, zusammen wenige hundert Byte. Das neue Manifest verweist auf die Code-Schicht, die schon in der Registry lag, sodass kein einziges Byte Code erneut übertragen wurde. Mit diesem Manifest hing der kubelet den Code unter /app ein, und der Composer-Autoloader fand alle Klassen.

In der Pipeline ändert sich dadurch nur der Push. Statt eines Artefakttyps gibt oras push eine Image-Config mit, und die Schicht bekommt den Medientyp eines Images:

 

printf '{"architecture":"arm64","os":"linux","config":{},
  "rootfs":{"type":"layers","diff_ids":["sha256:%s"]}}' \
  "$(sha256sum /tmp/app.tar | cut -d' ' -f1)" > /tmp/config.json

oras push \
  --config /tmp/config.json:application/vnd.oci.image.config.v1+json \
  "$REGISTRY/sources:$VERSION" \
  /tmp/app.tar:application/vnd.oci.image.layer.v1.tar

 

Die Änderung steckt in der gemeinsamen Pipeline-Komponente und gilt deshalb für TYPO3, Sylius und Laravel zugleich. Riskant war sie trotzdem nicht, denn die Schicht behält ihre org.opencontainers.image.title-Annotation. Daran erkennt oras pull, wohin es die Datei schreiben soll, und der alte Init-Container entpackt das neue Format genauso wie das alte. So konnte die Pipeline umgestellt werden, während alle Mandanten noch über ORAS liefen, und jeder Mandant wechselt danach für sich.

04 — Was schreibbar bleiben muss

Ein Image-Volume ist immer schreibgeschützt. Für die App-Container war das kein neuer Zustand, denn sie liefen schon vorher mit schreibgeschütztem /app, und für jedes Framework war gemessen, wohin es zur Laufzeit schreibt. TYPO3 braucht var/ und public/typo3temp/, dazu public/_assets/, das beim Setup veröffentlicht wird. Sylius schreibt unter var/ und veröffentlicht seine Bundle-Assets nach public/bundles/, Laravel braucht storage/ und bootstrap/cache/. Diese Verzeichnisse bekommen je ein eigenes emptyDir, das über dem Code eingehängt wird.

Der erste Pod mit dieser Aufteilung startete trotzdem nicht:

 

error mounting ".../volume-subpaths/..." to rootfs at "/app/var":
  make mountpoint "/app/var": mkdirat .../rootfs/app/var: read-only file system

 

Der kubelet sortiert die Mounts nach der Länge ihres Pfads, sodass /app vor /app/var eingehängt wird. Fehlt das Zielverzeichnis, legt runc es an, und auf einem schreibgeschützten Volume schlägt das fehl. Bisher hatte der Init-Container diese Verzeichnisse beim Entpacken selbst erzeugt, im Tar waren sie nie enthalten. Jetzt hängt die Pipeline sie als leere Verzeichnisse an das Tar an, mit einer eigenen Liste je Framework, die auch die Mountpunkte für den Objektspeicher wie public/fileadmin/ oder public/media/ enthält.

Zwei weitere Vorkehrungen stecken im Chart. Das Image-Volume trägt einen eigenen Namen und ersetzt nicht das bisherige emptyDir gleichen Namens. Argo CD wendet die Manifeste per Server-Side Apply an, und ein Feld, das noch einem anderen Field-Manager gehört, kann dabei stehen bleiben, sodass ein Volume zugleich emptyDir und image wäre und der Pod abgelehnt würde. Außerdem prüft ein kleiner Init-Container, ob der Autoloader im Volume liegt. Ein Release im alten Format würde sonst still mit leerem /app starten, so bricht er mit einer Meldung ab, die den Grund nennt.

05 — Der Pilot und was er gefunden hat

Den Anfang machte TYPO3, weil das Muster dort entstanden ist und die schreibbaren Pfade am besten vermessen waren. Als Pilot diente die eigene Website. Ein Schalter je Mandant wählt zwischen dem bisherigen Weg über ORAS und dem Image-Volume, und das Rollout nimmt den alten Pod erst aus dem Dienst, wenn der neue bereit ist. Fehler halten deshalb nur das Rollout an, Besucher merken davon nichts. Genau das ist im ersten Anlauf auch passiert.

Der neue Pod wurde nie bereit. TYPO3 brach beim Booten mit Could not create directory ab, und zwar mitten in vendor/. Die Extension Content Blocks verlinkt die Assets jedes Blocks beim ersten Aufbau ihrer Registry nach Resources/Public/ContentBlocks/ der jeweiligen Host-Extension, und die liegt nun einmal unter vendor/. Gemessen hatte ich die Schreibzugriffe nur am App-Container. Der lief schon länger mit schreibgeschütztem /app und fiel nie auf, weil der Setup-Init-Container diese Links vorher auf seinem beschreibbaren /app angelegt hatte. Die Lehre daraus: Schreibzugriffe misst man je Pod, nicht je Container.

Die Lösung gehört in den Build. Die Pipeline ruft vor dem Packen content-blocks:assets:publish auf. Das kommt ohne Datenbank aus, die Links sind relativ und stimmen deshalb auch unter /app, und zur Laufzeit findet der Publisher alles schon vor und schreibt nichts mehr. Ein Rückbau mit einer zweiten Falle gehörte auch dazu: Das Chart hatte im Image-Modus das Volume für den Code-Cache des Schedulers weggelassen, Argo hatte es gelöscht, und beim Rückweg hing es im Zustand Terminating, weil ein wartender Scheduler-Pod noch darauf zeigte.

Der zweite Anlauf lief sauber. Der Download beim Start fällt weg, statt 13 bis 14 Sekunden braucht der Init-Container nur noch einen Blick in das Volume, und ein Pod ist nach 42 statt 54 Sekunden bereit. Während der Umstellung lieferten 258 Abfragen gegen Website, zweite Domain und Backend keinen einzigen Fehler. Am selben Nachmittag folgten die übrigen TYPO3-Mandanten nach einem schreibgeschützten Boot-Test ihrer eigenen Images, mit gut 2000 Abfragen und keinem Fehler, der mit der Umstellung zusammenhing.

Ein Mandant hatte trotzdem noch etwas zu sagen. Bei ihm dauerte das Setup fast fünf Minuten, weil extension:setup zwei neue Standardwerte einer Extension in config/system/settings.php schreiben wollte und das Skript es so lange wiederholte, bis es aufgab. Die beiden Werte stehen jetzt in der Datei im Repository, und damit hat das Setup dort nichts mehr zu schreiben.

Sylius und Laravel folgen mit eigenen Schritten. Ihre Pipeline veröffentlicht den Code bereits im neuen Format, doch das Sylius-Setup schreibt noch JWT-Schlüssel und eine Preload-Brücke direkt nach /app. Diese Dateien brauchen erst einen schreibbaren Ort, bevor der Schalter für Sylius freigegeben wird, und bis dahin verweigert das Chart die Kombination.

06 — Was es gebracht hat

Im Pod läuft kein ORAS mehr, und der Download beim Start entfällt. Der kubelet hält jeden Release einmal je Node vor, für alle Pods, die ihn brauchen, und eine neue Version lädt nur die Schichten nach, die sich geändert haben. Der Scheduler braucht seinen Code-Cache nicht mehr, damit verschwindet auch das letzte ReadWriteOnce-Volume, das ihn an einen bestimmten Node band.

Die Trennung von Runtime und Code bleibt dabei vollständig erhalten. Das Runtime-Image wird weiterhin unabhängig von den Releases der Anwendungen aktualisiert. Die Signaturprüfung mit cosign läuft vor dem Start gegen denselben Digest, auf den auch das Volume festgelegt ist, und weil veröffentlichte Tags unveränderlich sind, ist pullPolicy: IfNotPresent hier unbedenklich.

Ein naheliegender nächster Schritt betrifft die Schichten selbst. Heute ist der ganze Code eine einzige Schicht, sodass jeder Release sie vollständig neu liefert. Getrennte Schichten für vendor/ und den eigentlichen Anwendungscode würden die Abhängigkeiten zwischen Releases auf dem Node halten. Der größere Brocken im Kaltstart ist jetzt das Framework-Setup, das bei jedem Pod-Start läuft. Mit dem Code im Image-Volume hat ein Argo-Hook zum ersten Mal Zugriff auf den Code, und damit kann das Setup einmal je Release laufen statt in jedem Pod. Ein erster Versuch brachte einen Pod auf 23 Sekunden und musste trotzdem zurück, weil das Setup nebenbei auch die öffentlichen Assets des Pods anlegte. Wie das ausgeht, steht in einer eigenen Feldnotiz, zusammen mit vorgewärmten Caches aus dem Build.

Häufige Fragen

Warum nicht gleich ein Image mit Runtime und Code bauen?+

Weil dann jedes Sicherheitsupdate der Runtime einen neuen Release jeder Anwendung bräuchte, egal ob TYPO3, Sylius oder Laravel. Mit dem Image-Volume bleiben beide getrennt: Die Runtime kommt als Container-Image, der Code als zweites Image, und der Pod setzt beide erst beim Start zusammen.

Bleibt die Signaturprüfung erhalten?+

Ja. Vor dem Start prüft cosign Signatur und SBOM-Attestierung gegen das Manifest in der Registry. Das Volume ist über denselben Digest festgelegt, also hängt der kubelet genau das ein, was geprüft wurde.

Was passiert mit Releases im alten Format?+

Sie laufen weiter über ORAS. Der Schalter gilt je Mandant, und umgestellt wird erst, wenn der laufende Release im neuen Format vorliegt. Wer es früher versucht, bekommt keine leere Seite, sondern einen Init-Container, der mit einer klaren Meldung abbricht, während der alte Pod weiter ausliefert.

Fazit

Ein Image-Volume ersetzt den Init-Container, der Code bei jedem Start herunterlädt und entpackt, und zwar für jede PHP-Anwendung, die ihren Code getrennt von der Runtime ausliefert. Dazu reicht es, die vorhandene Schicht unter ein Image-Manifest zu stellen und die Mountpunkte der schreibbaren Verzeichnisse mitzuliefern. Das war der leichte Teil.

Die eigentliche Lehre steckt im ersten Versuch. Ein Artefakt, das nicht wie ein Image aussieht, hängt der kubelet ein, ohne sich zu beschweren, und liefert ein leeres Verzeichnis. Ein gestarteter Pod beweist bei dieser Umstellung wenig. Aussagekräftig ist erst ein Blick in das Volume, und am besten prüft das ein Init-Container bei jedem Start.

Lädt dein Cluster bei jedem Start denselben Code?

Ich sehe mir an, wie der Code deiner TYPO3-, Sylius- oder Laravel-Anwendung in den Pod kommt: was bei jedem Start geladen wird, was sich als Image-Volume eignet und wie sich die Umstellung ohne Ausfall planen lässt.

Termin buchen →

Über den Autor

Foto von Kai Ole Hartwig.

Kai Ole Hartwig

Freiberuflicher DevSecOps-Berater · OnlyOle Consulting

Programmiert seit 2002 – autodidaktisch gelernt, 2012 mit KO-Web selbständig gemacht. Über 100 Projekte, Fokus auf Security, Performance, Automatisierung und Qualität. Heute freiberuflich: DevSecOps-Beratung, Schulungen und Softwareentwicklung.