Kai Ole Hartwig
10 Min. Lesezeit
Von

Code als OCI-Artefakt: TYPO3 ausliefern, ohne den App-Code ins Image zu backen

Der klassische Container-Weg backt den Anwendungscode per COPY ins Image — und macht damit jeden Code-Release zum kompletten Image-Build. Für eine TYPO3-Installation, deren Code sich oft ändert, deren Runtime aber selten, ist das die falsche Kopplung. Dieser Beitrag zeigt, wie ich den Code stattdessen per ORAS als eigenes OCI-Artefakt neben die Images lege, was das im Deploy-Alltag bringt — und wo die ehrlichen Grenzen des Musters liegen.

TL;DR

Statt den App-Code in ein Image zu backen, baue ich ihn als eigenes OCI-Artefakt: Die CI packt vendor/, public/, packages/ und config/ als Tarball und pusht ihn per oras push neben die Images in dieselbe Registry — gleiche Auth, gleiches Tag-Schema. Am Deploy zieht ein einmaliger oras-init-Container das Artefakt in ein Shared Volume; die Services laufen auf einem leeren Golden Image. Ein Code-Release ist damit ein Tarball-Push in Sekunden statt ein Image-Build, Runtime und Code rollen unabhängig voneinander, und Renovate bumpt den Sources-Tag wie jeden Image-Tag. Der Preis: ein Init-Step, weil containerd OCI-Artefakte (noch) nicht nativ als Volume mountet.

Das Problem: Code und Runtime kleben aneinander

Der klassische Container-Weg: Du nimmst ein PHP-Base-Image, kopierst den gebauten Anwendungscode hinein, baust daraus ein „fettes“ Applikations-Image und pushst es pro Release. Funktioniert — hat aber zwei unangenehme Eigenschaften. Erstens: Jeder Code-Release ist ein kompletter Image-Build. Auch wenn sich nur ein Template geändert hat, wird ein neues Multi-Layer-Image gebaut, signiert und gepusht. Zweitens: Runtime und Code sind gekoppelt. Ein PHP-Security-Update zwingt zum Rebuild aller App-Images, ein Code-Fix schleppt die Runtime mit.

Für eine TYPO3-Installation, deren Code sich oft ändert, deren Runtime (FrankenPHP, Caddy, CrowdSec) aber selten, ist das die falsche Kopplung. Meine Antwort: den Code vom Runtime-Image entkoppeln und als eigenes OCI-Artefakt ausliefern.

Was ist ein OCI-Artefakt überhaupt?

Eine Container-Registry kann mehr als nur Images. Seit der OCI-Spec 1.1 ist ein „Artefakt“ einfach beliebiger Inhalt mit einem Manifest und einem artifactType — ein Tarball, ein Helm-Chart, ein SBOM, ein Signatur-Blob. Es liegt in derselben Registry, unter demselben Tag- und Digest-Schema, mit derselben Auth wie ein Image. Nur ist es eben kein lauffähiges Image, sondern nutzlast-agnostische Bytes.

Das Werkzeug dafür ist ORAS (OCI Registry As Storage). oras push und oras pull verhalten sich wie docker push und docker pull, akzeptieren aber jede Datei als Layer.

Bei mir ist die Nutzlast das gebaute app/-Verzeichnis: vendor/ (Composer), public/, packages/ (Sitepackages), config/, plus composer.lock, preload.php und patches.lock.json.

Der Build: oras push in der CI

Der Build läuft bei jedem Git-Tag. Ein vorgelagerter Job (build:site:backend) erzeugt das komplette app/-Verzeichnis als Job-Artefakt; der ORAS-Job verpackt und pusht es:

 

oras:push:sources:
  stage: sources
  needs:
    - job: build:site:backend
      artifacts: true
  image:
    name: ghcr.io/oras-project/oras:v1.2.3
    entrypoint: [""]          # (1)
  rules:
    - if: $CI_COMMIT_TAG
  before_script:
    - oras login -u gitlab-ci-token -p "$CI_JOB_TOKEN" "$CI_REGISTRY"   # (2)
  script:
    - apk add --no-cache tar
    # Nur die laufzeitrelevanten Pfade — kein docker/, .git/, .gitlab/
    - |
      tar -cf /tmp/app.tar \
        composer.json composer.lock preload.php patches.lock.json \
        vendor public packages config
    - |
      oras push \
        --artifact-type "application/vnd.example.typo3.sources" \
        --disable-path-validation \                                    # (3)
        "$REGISTRY_BASE/sources:$CI_COMMIT_TAG" \
        /tmp/app.tar:application/vnd.example.typo3.sources.tar         # (4)

 

Vier Stolpersteine, die du kennen musst.

(1) Entrypoint-Override

Das oras-Image hat oras als Entrypoint. GitLab CI wrappt Script-Kommandos aber in sh -c "…" — ohne entrypoint: [""] wird daraus oras sh -c "…" und der Job stirbt mit unknown command "sh".

(2) Auth mit dem Job-Token

Kein extra Secret: gitlab-ci-token plus $CI_JOB_TOKEN reicht, um in die projekteigene Registry zu pushen. Dasselbe Muster wie beim Image-Push.

(3) --disable-path-validation

ORAS blockt absolute Pfade (/tmp/app.tar) per Default als Path-Traversal-Schutz. Da der Pfad nur ein temporäres CI-Verzeichnis ist und der tar-Inhalt relativ, ist das Risiko hier null — aber das Flag muss gesetzt sein.

(4) Eigener Media-Type

artifactType und der Layer-Media-Type (application/vnd.example.typo3.sources.tar) sind selbstdefinierte vnd.*-Typen. So weiß jeder Consumer sofort: Das ist ein TYPO3-Sources-Tar, kein Image.

Ein hübsches Detail: Promotion geht mit demselben Tool. Ein Image von sha-… auf den Release-Tag umzuhängen ist einfach oras cp — ORAS kopiert Images und Artefakte gleichermaßen:

 

oras cp "$REGISTRY_BASE/scheduler:sha-1a2b3c4d" "$REGISTRY_BASE/scheduler:$CI_COMMIT_TAG"

Der Deploy: oras pull in einem Init-Container

Auf der Prod-Seite läuft ein einmaliger oras-init-Service (Docker Compose), der das Artefakt zieht, entpackt und sich beendet. Die eigentlichen Services warten via depends_on: service_completed_successfully:

 

services:
  oras-init:
    image: ghcr.io/oras-project/oras:v1.2.3
    restart: "no"
    user: "1000:1000"
    entrypoint: ["/bin/sh", "-c"]
    command:
      - |
        set -eu
        # Stale-Content vom letzten Deploy weg (Bind-Mount selbst bleibt)
        find /shared -mindepth 1 -delete
        oras pull "registry.example/…/sources:${SOURCES_TAG}" -o /shared
        tar -xf /shared/app.tar -C /shared && rm /shared/app.tar
    volumes:
      - /var/www/shared/production/sources:/shared
      - /root/.docker/config.json:/.docker/config.json:ro   # Auth
    environment:
      DOCKER_CONFIG: /.docker

  httpd:
    image: registry.example/images/php-runtime:1.0.3          # golden, LEER
    volumes:
      - /var/www/shared/production/sources:/app               # Code aus dem Artefakt
    depends_on:
      oras-init:
        condition: service_completed_successfully

 

Die Architektur in einem Bild:

 

   BUILD (bei git tag)                REGISTRY                 DEPLOY (bei tag-bump)
 ┌────────────────────┐        ┌───────────────────┐      ┌───────────────────────┐
 │ build:site:backend │        │  sources:v1.2.3   │      │  oras-init (once)     │
 │        │           │  push  │  (vnd.…typo3.tar) │ pull │   find -delete        │
 │        ▼           │ ─────► │                   │ ───► │   oras pull -o /shared│
 │  tar app.tar       │  oras  │  scheduler:v1.2.3 │ oras │   tar -xf app.tar     │
 │  (vendor+public+…) │        │  (golden runtime) │      │        │              │
 └────────────────────┘        └───────────────────┘      │        ▼              │
                                                           │  /shared ──► /app     │
   Runtime-Images (php-runtime, caddy, crowdsec)           │  httpd│scheduler│queue│
   werden SEPARAT & selten gebaut ──────────────────────►│  (golden, kein Code)  │
                                                           └───────────────────────┘

 

Der Kern: httpd, scheduler und queue laufen auf dem golden php-runtime-Image ohne eingebetteten Code. Der Code kommt zur Laufzeit aus dem Shared Volume, das oras-init befüllt hat. Ein Code-Release ist damit ein Tarball-Push, kein Image-Build. Und weil der SOURCES_TAG eine gitlab-tags-Datasource ist, bumpt Renovate ihn im Deploy-Repo automatisch — der GitOps-Deploy fällt raus wie bei jedem Image-Tag.

„Warum mountest du das Artefakt nicht einfach direkt?“

Die berechtigte Frage — und der ehrlichste Teil. Kubernetes hat seit 1.31/1.33 eine Image-Volume-Quelle (KEP-4639), mit der ein Pod ein OCI-Image als Read-only-Volume mounten kann. Naheliegend, das für das Sources-Artefakt zu nutzen und den Init-Container zu sparen.

Es scheitert an zwei Dingen. Erstens: Image-Volume will ein Image, kein Artefakt. Ein Artefakt mit eigenem artifactType ist kein lauffähiges Image — der Kubelet mountet es nicht. Zweitens: Native OCI-Artefakt-Volumes gibt es nur unter CRI-O, nicht unter containerd. Und k3s fährt containerd. Es gibt für diesen Stack also schlicht keinen Runtime-Pfad, der ein OCI-Artefakt direkt als Volume mountet.

Fazit dieser Recherche: Solange containerd keine Artefakt-Volumes kann, bleibt der oras pull in einem Init-Step der einzig gangbare Weg — und ein 10-Zeilen-Sidecar ist ein sehr kleiner Preis.

Vorteile

Entkopplung von Code und Runtime. Golden Images (PHP, Caddy, CrowdSec) werden selten und unabhängig gebaut; ein PHP-Security-Update rollt ohne Code-Rebuild, ein Code-Fix ohne Runtime-Rebuild.

Kein fetter Image-Build pro Release. Ein Release ist ein tar plus oras push — Sekunden statt Multi-Stage-Buildkit-Lauf.

Eine Registry, eine Auth, ein Werkzeug. Das Artefakt liegt neben den Images, dieselbe oras login-Auth mit dem Job-Token, oras cp für Promotion. Kein separater Artefakt-Store — kein Nexus, kein extra S3-Bucket.

Immutable, versioniert, digest-gepinnt.sources:v1.2.3 ist unveränderlich; die Registry liefert Retention, GC und Replikation gratis mit.

Architektur-agnostisch. Ein Tarball hat keine CPU-Architektur. Dasselbe Artefakt läuft auf amd64- wie arm64-Runtime — kein Multi-Arch-Build nötig, ein realer Schmerzpunkt, den ich von Images kenne.

GitOps-fähig. Renovate bumpt den Sources-Tag wie jeden Image-Tag — automatischer, nachvollziehbarer Deploy.

Signierbar. cosign signiert OCI-Artefakte genauso wie Images — Supply-Chain-Provenance bleibt möglich.

Ehrliche Fußnoten

Nicht nativ mountbar (containerd). Der Init-Container ist Pflicht, samt Race-Handling (service_completed_successfully) und Shared-Bind-Mount-Lifecycle. Ein zusätzliches bewegliches Teil.

Stale-Content ist Handarbeit. Das Shared-Verzeichnis muss vor dem Entpacken aggressiv geleert werden (find /shared -mindepth 1 -delete), sonst überleben alte Dateien den Deploy. Ein Image-Pull würde das gratis erledigen.

Symlink-Fragilität. Der tar muss relative Symlinks erhalten (packages/acme → ../vendor/acme); ein -h bricht sie. Dass sie nach dem Extract auflösen, musste ich erst beweisen.

Keine Layer-Dedup zwischen Releases. Jeder sources:tag ist ein voller Blob; zwei Releases, die sich 90 % vendor/ teilen, dedupen nicht wie Image-Layer. Der Registry-Storage wächst pro Release — per Retention und GC beherrschbar.

Auth-Plumbing am Deploy. ORAS zieht mit ~/.docker/config.json vom Host (ein docker login vor compose up) — der Kubelet bringt seine Registry-Creds hier nicht mit, du lieferst sie selbst.

Kleine Papierschnitte. Entrypoint-Override, --disable-path-validation — nichts Dramatisches, aber du stolperst einmal darüber.

Weniger ausgetretener Pfad. ORAS ist solide, aber OCI-Artefakte sind Nische; genau deshalb kann containerd sie ja (noch) nicht mounten. Weniger Leute im Team kennen das Muster.

Häufige Fragen

Wie kommt der Deploy an die Registry-Credentials?+

ORAS liest die ~/.docker/config.json — am Host reicht ein einmaliges docker login vor compose up, die Datei wird read-only in den oras-init-Container gemountet. In der CI übernimmt das der Job-Token (gitlab-ci-token + $CI_JOB_TOKEN), ganz ohne extra Secret.

Wächst die Registry nicht mit jedem Release?+

Ja — jeder sources:tag ist ein voller Blob ohne Layer-Dedup. In der Praxis ist das per Retention-Policy und Garbage Collection der Registry beherrschbar; dafür entfällt der separate Artefakt-Store komplett.

Warum nicht das Kubernetes-Image-Volume (KEP-4639) nutzen?+

Weil es ein Image erwartet, kein Artefakt — ein Blob mit eigenem artifactType mountet der Kubelet nicht. Native OCI-Artefakt-Volumes gibt es bisher nur unter CRI-O, und k3s fährt containerd. Bis sich das ändert, bleibt der oras-init-Step die pragmatische Brücke.

Fazit: Wann lohnt sich das Muster?

Wenn deine Runtime stabil und golden ist, dein Code sich oft ändert und du deine Registry ohnehin als zentrale, signierbare, GitOps-getriebene Source of Truth nutzt — dann schlägt ein kleines, getaggtes Sources-Artefakt sowohl den fetten Per-Release-Image-Build als auch einen separaten Artefakt-Store.

Der einzige echte Wunsch bleibt der native Mount. Bis containerd OCI-Artefakt-Volumes kann, ist der oras-init-Sidecar die pragmatische Brücke — und die kostet zehn Zeilen YAML.

Stack-Kontext: TYPO3 14, FrankenPHP Worker Mode, Docker Compose auf k3s/Graviton, GitLab Container Registry (S3-backed), Renovate-getriebenes GitOps, ORAS v1.2.3.

Bevor der nächste Ein-Zeilen-Fix wieder einen Image-Build kostet — sprechen wir über deine Deployment-Architektur.

Deployments, die zur Änderungsrate passen

Wenn bei dir jeder Template-Fix einen kompletten Image-Build anstoßt — oder Runtime-Updates an den Code-Releases hängen —, schaue ich mir mit dir an, welche Einheit sich bei dir wirklich ändert und wie sie ausgeliefert werden sollte. Ergebnis: Golden Images, die selten rollen, Code-Releases in Sekunden und eine Supply Chain, die signierbar bleibt.

Beratung anfragen

Freiberuflich · DevSecOps-Beratung, Schulungen & Softwareentwicklung

Ü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.