WIP: Bau den perfekten Docker Basis-Container

Vielleicht ist der Anspruch ein wenig hoch. Aber ich habe einige Wochen damit verbracht, best-practices für den Bau von Container-Image überall im Internet zu sammeln und festgestellt: So richtig den zentralen Anlaufpunkt mit alles Aspekten gibt es nicht. Daher ist dies nun die drölfzigste aller Website, die den Anspruch für sich erhebt, der beste Docker-Guide der Welt zu sein ;-).

Der “beste” Docker Guide ist natürlich schwer subjektiv. Was sind meine Kritierien dafür:

  • Leicht zu lesen (Im besten Fall machts Spaß 😉 )
  • Gut nachvollziehbar
  • Ein guter Kompromiss aus “Security” und “Simplyness”
  • Walkthrough Charakter mit Blick über den Tellerrand -> Du sollst schnell zum Ziel kommen, aber ich verlinke dir so viele Infos wie möglich.
  • “Möglichst” geringe Komplexität. Ich beschreibe hier nur Dinge, die ich selber nach 10 Minuten lesen verstehe und die auch konkret helfen das Ziel zu erreichen (-> App muss laufen). Dinge wie die Definition der ChainID sind sicher in Einzelfällen auch spannend, aber u.U. nicht in jedem Fall zielführend ;-).

Inhalt und Einleitung

Damit du schnell einsteigen kannst, hier ein Inhaltsverzeichnis mit Sprungpunkten. Wenn du alles erfahren möchtest, lies einfach weiter.

Offene Themen / Backlog

  • Wenn ein build fehlerhaft ist (package in mariadb kann nicht installiert werden) läuft der build trotzdem durch und man merkt es nicht. Hier muss ich mal herausfinden wie man sicher mit Fehler einen Docker-Build verlässt und die Github Action auch fehlschlägt.
  • Timezone setzen mit tzdata
  • Am Beispiel mariadb -> Alles nach stdout loggen

Zielpublikum und Motivation

Dieser Artikel richtet sich an Leute die Ihre eigenen Container Images bauen wollen. ZIelpublikum sind explizit Anfänger, du brauchst also wenig Vorerfahrung mit Containern, solltest aber verstanden haben, warum es cool ist, Anwendungen in Container zu verpacken. Aber auch für den Profi möchte ich hier einen möglichst vollständigen Walkthrough anbieten, um möglichst schnell eine funktionstüchtige Umgebung ans laufen zu bekommen. Weiterhin geht es hier nur um das bauen des Images, nicht um den Betrieb oder das Deployment (mal sehen was noch kommt).

Perfektion…

… ist nicht das erste Ziel. Wenn du was siehst was verbessert oder ergänzt gehört, dann scheu dich nicht, mich direkt oder unter diesem Artikel anzuschreiben. Dieser Text soll leben, sich ständig verändern und den aktuellen Gegebenheiten anpassen.

Haftung und so

Ganz klar, du bist für deine Images/Container, darin laufenden Anwendungen, sowie für die zugrunde liegenden Hostsysteme immer selbst verantwortlich. Nutzt du die hier verwendeten Images oder Codebeispiele, enthebt dich das nicht von deiner Verantwortung gegenüber deiner Umgebung/deiner Firma. Ich schreibe und pflege diesen Artikel nach bestem Wissen und Gewissen und komme auch nur so oft dazu wie ich Zeit und natürlich auch Lust habe. Komm mir also nicht mit “Der Hack war nur möglich, weil der Frickeldave es so empfohlen hat”. Immer dran denken, eigentlich ist ja total unseriös von nem Typen mit dem Spitznamen was zu übernehmen ;-). Neben den Empfehlungen in diesem Artikel, die sich rein auf den “Bau” der Container-Images beziehen, empfehle ich auf jeden Fall einen Blick in weiter unten verlinkten BSI/CSI Handbücher.

Begriffserklärung

  • Image
    • Ein Image beinhaltet die OS-und Applikationsteile die für den Betrieb der Anwendung notwendig sind.
  • Layer
    • Images sind in Layer unterteilt. Jede Aktion (Copy file; Execute command;…) die den Inhalt verändert, wird in einem extra Layer festgehalten. Um es ein wenig verwirrender zu gestalten, beschreibe ich in diesem Artikel die aufeinander-aufbauende Images um letztendlich zu einer Anwendung zu gelangen auch als “Layer”. Natürlich hat docker da bei mir geklaut, ist klar ;-). Im weiteren Verlauf spreche ich hier von “Docker-Layer” wenn ein durch ein Befehl im Dockerfile entstandener Layer gemeint ist.
  • Container
    • Die Instanzen die aus einem Image heraus gestartet werden.
  • Registry
    • Eine zentrale Serverkomponenten auf der Images und ggf. dazugehörige Metadaten strukturiert abgelegt werden können. Je nach Design der Umgebung finden hier auch verschiedene Wartungsjobs und Sicherheitsprüfungen statt.
    • Die folgende Liste zeigt die, die ich schon in Verwendung hatte. Diese Liste ist aber bei weitem nicht vollständig.
      • quay.io -> Die RedHat Registry für RedHat eigene Systeme oder Systeme die RedHat als für sich sinnvoll erachtet
      • Azure Container registry -> Zur Ablage von Container Images die in Azure Verwendung finden.
      • Google container registry -> Zur Ablage von Container Images die in der Google Cloud Verwendung finden.
      • Amazon ECR -> . Amazon bietet hier neben Ihrer privaten Registry auch noch eine Public Registry ähnlich docker-hub oder quay.io an.
      • Github container registry -> Erlaubt die Ablage von Docker Images im Rahmen von github Projekten.
    • Neben den verschiedenen über das Internet erreichbaren Registries, gibt es noch diverse On-Prem Produkte um die eigene Registry aufzubauen. Die bekanntesten (aber bei weitem auch nicht alle) Vertreter sind:
      • Sonatype Nexus OSS/Pro (Bietet neben einer Container Registry auch die Verwaltung weiterer Repos wie maven)
      • JFrog Artifactory (Bietet neben einer Container Registry auch die Verwaltung weiterer Repos wie maven)
      • VMWare Harbor
      • Gitlab Container registry (Teil der kompletten GitLab Build Pipeline)
  • Dockerhub
    • Eine öffentliche Registry, verwaltet von Docker. Neben Dockerhub existieren noch weitere öffentliche Registries, oft herstellerbezogene (siehe oben).
  • Volume
    • Eine Datei oder ein Storage in dem persistente Daten außerhalb des Containers gespeichert werden. Im Umkehrschluss: Alle Änderungen im Container sind grundsätzlich nicht persistent.
  • Official Image
    • Images auf Dockerhub die definierten Prüfkriterien unterliegen. Details siehe hier.
  • Base Image
    • Images können in einer Baum-Hierarchie aufeinander aufbauen. Das erste Image in der Hierarchie bezeichne ich hier als “Base Image”. Dies hängt im Rahmen dieses Artikels nicht mit der von Docker verwendeten Terminologie zusammen, in der das Base Image “FROM scratch…” gebaut wird.
  • Tag
    • Images können mit beliebigen Tags versehen werden. In der Regel existiert immer ein Tag “latest”. Dazu gesellen sich normalerweise Tags die die Version der Applikation widerspiegeln, die mit dem Image angeboten wird. Die Anzahl der Tags ist aber bei weitem nicht auf “2” beschränkt.
  • Dockerfile
    • In dieser Datei wird der Inhalt der Images spezifiziert. Der Dateiname ist nicht festgelegt, es hat sich aber als “best-practice” etabliert, diesen zu verwenden (selbst wenn nicht “docker” als Build Tool verwendet wird).

Verwendete Quellen

Die hier gelisteten Dokumente solltest du immer durcharbeiten, wenn du Container-Images in deiner Umgebung bereitstellen musst. Dieser Artikel ersetzt insbesondere die Dokumente des BSI nicht, sondern konkretisiert nur die Art und Weise wie Container-Images gebaut werden sollten.

  • BSI SYS 1.6 Containerisierung vom 9.11.2021
    Beschreibt die allgemeine Herangehensweise bei der Planung der Containerinfrastruktur. Hier geht es mehr um organisatorische Dinge die erledigt werden müssen, weniger um konkrete technische Details.
  • CIS Docker Benchmark v1.3.1
    Beschreibt konkrete technische Details die auf der Infrastruktur, dem Host, im Image und dem resultierenden Container konfiguriert werden sollten.
  • Open container initiative
    Eine offene Vereinigung verschiedener Hersteller um einen einheitlichen Standard zur Definition von Container Umgebungen zu schaffen. Dies umfasst auch diverse Best practices.
  • Und last bot not least: Das Internet 😉

Git Repositories

Alle hier gezeigten Lösungen sind in Repositories auf Github verfügbar. Die folgenden werden dabei in diesem Artikel behandelt:

  • ContainerBase
    Hier speichere ich alle Images ab, die als Basis für den Betrieb von Anwendungen notwendig sind und keine weitere Abhängigkeit (außer zum Basis Alpine Image) haben.
  • ContainerRabbitMQ
    RabbitMQ als zentraler Container für Message Queues.
  • ContainerKeycloak
    Ich verwende Keycloak als Identity Management System in meinen Anwendungen.
  • ScriptCollection
    Verwendete Scripte und die Build-Pipeline Dateien findest du hier.

Image Struktur

Aus den gezeigten Git-Repositories entsteht schon eine gewisse Image Struktur. Diese habe ich dir zum besseren Verständnis in der folgenden Grafik visualisiert.

Images werden in Layer unterteilt. Ganz oben steht der Base Layer mit dem (oder den) Base-Image(s), darunter folgt der Intermediate Layer mit den Runtimes, dann der Infrastrukturlayer mit Applikationen und zu guter letzt die Anwendungsimages.
Image Hirarchie

Die Image-Hierarchie beschreibt, wie Images voneinander erben. Grade in größeren Umgebungen hilft ein solches Modell sehr bei der Strukturierung der Images.

Pro Tip: Wenn es dein Arbeitsumfeld zulässt, überlege Dir eine Layer-Struktur, visualisiere diese auf einem DIN-A0 Poster und mache Dir pro Image was Du neu baust ein Post-It. Das klebst du in die richtige Zeile. Ab und zu aktualisierst du das zugrunde-liegende Poster und hast so mit wenig Aufwand eine immer aktuelle Dokumentation. Alternativ kannst du auch Lösungen wie “Conceptboard“, ein “digitales Whiteboard” oder im worst ein shared Powerpoint Dokument verwenden. Aber eine “physikalische Wand” wird ein digitales Medium leider nie ganz ersetzen können.

Base
Layer
Ganz oben steht das Base Image, dass alle Tools und Scripte enthält, die jeder Anwendung zugänglich sein müssen. Ich verwende “Alpine-Linux” im Base Image. Mehr dazu später. Natürlich ist das Base Image nicht auf ein einziges Image beschränkt, es ist auch denkbar neben Alpine ein weiteres OS anzubieten. Dieser Schritt sollte aber gut begründet sein.
Intermediate LayerEs folgt ein Intermediate Layer der die Images für die Runtimes enthält. Hier sind die Runtimes installiert und konfiguriert. So werden im Falle der Java Runtime z.B. die Zertifkate im Java-Keystore aktualisiert. Nun mag auffallen, dass ich mariadb und nginx, die ja offensichtliche Infrastrukturkomponenten sind, einfach in den Intermediate Layer geschoben habe. Das ist tatsächlich nicht ganz sauber, aber darin begründet, dass mariadb und nginx keine Abhängigkeiten zu einer anderen Runtime haben und ich den Build-Prozess so gestaltet habe, dass die obersten beiden Schichten immer gemeinsam aktualisiert und gewartet werden. Und ein zusätzlicher Layer (Bsp. Core Infrastruktur) war für meine Zwecke dann doch “too much”.
Infrastruktur LayerDiese Layer ist zwar nicht wirklich statischer Teil der Hierarchie aber wird von den Anwendungsimages verwendet, es ergibt sich also eine logische Verkettung. Eine Besonderheit fällt Dir hier vlt. auch auf: Während das keycloak Image direkt von von Java Runtime erbt, steht beim RabbitMQ “copy” am Pfeil. Hintergrund hierfür ist, dass es Verhältnismäßig komplex ist, RabbitMQ von Grund auf nachzubauen, die Maintainer aber ein alpine basiertes Images anbieten. Ich habe also das Original RabbitMQ-Alpine Image als Grundlage verwendet, alle Befehle meines eigenen Alpine Base Image dort herein kopiert (was natürlich über kurz oder lang automatisiert wird).
Anwendungs – LayerZu guter letzt kommen die Anwendungen die von den Intermediate-Images direkt erben und optional Infrastruktur-Images verwenden.
Beschreibung der Layer

Alpine als “Base Image”

Folgende Möglichkeiten bieten sich an, das Basis Image zu gestalten:

  • Nutzen eines Basis-Image von dockerhub, oder einer alternativen Registry
    • Vorteile: Schnell, Relativ sicher bei “official images”, wenig Aufwand
    • Nachteile: Wenig Aktionsspielraum wenn etwas “auffällig” ist. In manchen Umgebung wird Images aus dem Netz “per default” misstraut.
  • Bau from Scratch (Beispiel hier)
    • Vorteile: Du kannst schnell reagieren, Meist aktueller als Image von dockerhub
    • Nachteile: Aufwand schon deutlich höher als bei fertigem Image
  • Distroless
    • Vorteile: Es sind wirklich nur die Applikationsteile im Image enthalten -> höchste Sicherheit. In sehr großen komplexen Umgebungen kann der Aufwand tatsächlich auch wieder geringer sein, als alles “von Hand” zu pflegen.
    • Nachteile: Aufwand sehr hoch, sehr viel KnowHow notwendig

Mir geht es immer um einen guten “Kosten/Nutzen” Faktor. Zudem behaupte ich, dass mindestens beim Bau “from scratch” nicht zwingend mehr Sicherheit geboten ist. Baue ich denn das Image wirklich korrekt? Betrachten die Maintainer des “Official Image” vielleicht auch Dinge, die mir entgehen würden? Habe ich die Ressourcen verfügbar um schnell Sicherheitslücken aufzudecken und zu schließen?
Es gibt sicherlich Umgebungen, in denen es Sinn macht seine eigenen Images zu bauen, allerdings solltest du Dir gut überlegen, diesen Invest wirklich zu leisten. In den meisten Umgebungen (selbst Enterprise) ist dies i.d.R. nicht möglich, werden wir auch hier ganz klar ein fertiges Images von dockerhub verwenden. Das BSI sagt hierzu folgendes (Ausschnitt):

“Es MUSS sichergestellt sein, dass sämtliche verwendeten Images nur aus vertrauenswürdigen Quellen stammen. Der Ersteller der Images MUSS eindeutig identifizierbar sein.

Ich habe mich entschieden, Alpine als Basis Image für alle andere Images zu nutzen. Alpine ist zum einen extrem schlank, zum anderen fällt es, dadurch dass relativ wenig Komponenten mit an Board sind (z.B. auch keine bash), in Sicherheits-Scans meist nicht negativ auf. Sollten einmal Sicherheitslücken existieren, ist zudem relativ schnell eine neue Version verfügbar.

Eine wichtige Anmerkung soll aber hier nicht ungenannt sein: In Alpine ist die C-musl-Bibliothek in Verwendung, was in einzelnen Fällen schon mal zu Probleme führen kann. Ich persönlich habe Alpine nun schon in einigen Enterprise Umfeldern im Einsatz gehabt und wenig bis gar keine Probleme damit identifiziert.

Wichtiges Know-How zum Thema “Layer”

Bevor du anfängt ein Container Image zu bauen, ist es wichtig zu verstehen, wie Images aufgebaut sind. Die Art und Weise, wie (und in welcher Reihenfolge) du die Befehle schreibst, hat maßgeblichen Einfluss auf die Effektivität deines resultierenden Images.

Jedes Docker Image besteht auf mehreren Schichten, sogenannten (Docker-)Layern. Jeder build Befehl fügt einen Layer dem Docker Image hinzu. Jeder Layer repräsentiert daher einen Diff zum entsprechend vorherigen Stand des Images. Die Layer sind bis auf den letzten jeweils letzten immer schreibgeschützt.  

Docker Layer

Natürlich besteht auch das jeweils verwendete Basis-Image wiederum aus verschiedenen Layern. Wird ein Image ausgeführt (ein Container erzeugt), wird stillschweigend ein Layer hinzugefügt, der schreibbar ist. Dieser Layer wird automatisch entfernt, wenn der Container gestoppt wird. Daher eignet sich dieser Layer ausschließlich für nicht-persistente Daten.

Inspizieren von Layern

Jeder Layer ist mit einer eindeutigen ID versehen. Die IDs lassen sich mit docker inspect als geordnete Liste auslesen.

appuser@mydockerhost:~$ docker inspect 4061ea1a69d3
[
...

...
...
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:8d3ac3489996423f53d6087c81180006263b79f206d3fdec9e66f0e27ceb8759",
"sha256:f3a690c714aaf5c1d482d5d8568e2e04dd925cb400241b8dc597ae41e670167d",
"sha256:aeb4d449d0c422353f8da95cdbcf0b5b69dae27109bb81baea5a8602b4f9f469",
"sha256:874262a4982391cef3ca89f4d787616dacd2fe71ebe940d1fb31ee7bdb13d439",
"sha256:2e713ead382acfd4446bdaecf63ea18885f2c91bddf488d2f89b981e87e22f96",
"sha256:6e0394ccf2fe58cc3f7c89cad169a61b97780c85f8097e49b7d40a5b4681b927",
"sha256:771a0c5ecca00425c7d13eb844b0db3a5a2f1b0b01047c88c451be893ed41082"
]
}...

...
...

Um zu sehen, was in jedem Image passiert, stellt docker den Befehl “history” zur Verfügung. Hier ein Ausschnitt der Layer Struktur des weiter unten gebauten Images.

appuser@hildegard:~$ docker history 4061ea1a69d3
IMAGE CREATED CREATED BY SIZE COMMENT
4061ea1a69d3 8 days ago /bin/sh -c chown -R appuser:appuser /home/ap… 3.23kB
f74bb1a0d6a7 8 days ago /bin/sh -c curl https://raw.githubuserconten… 3.23kB
ad55aeeba5e2 8 days ago /bin/sh -c apk update; apk --no-cache add… 3.89MB
26c190f0f40f 8 days ago /bin/sh -c chown -R appuser:appuser /home/ap… 0B
687017a7159f 8 days ago /bin/sh -c mkdir /home/appuser/data; mkdi… 0B
7d5a73acb871 8 days ago /bin/sh -c adduser -D -h "/home/appuser" -u … 4.68kB
e2fb400eb413 8 days ago /bin/sh -c #(nop) LABEL de.frickeldave.cont… 0B
fc12a380b944 8 days ago /bin/sh -c #(nop) LABEL de.frickeldave.cont… 0B
18abae4e05bc 8 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
e6885b0c3fc8 8 days ago /bin/sh -c #(nop) LABEL org.opencontainers.… 0B
ae6567...
...
....
f2022ac81464 8 days ago /bin/sh -c #(nop) ARG fd_buildnumber 0B
a007cdac3d96 8 days ago /bin/sh -c #(nop) ARG fd_builddate 0B
c059bfaa849c 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B

Tip: Hier findest du das Tool “Dive” mit dem du Container Images noch besser analysieren und optimieren kannst.

Zusammenfassen von Befehlen

Der vorherige Ausschnitt zeigt sehr gut, dass jeder Befehl, einen Layer erzeugt. Daraus folgt eine wichtige Konsequenz: Wenn du in einem Befehle irgendwelche temporären Dateien erzeugst und in einem zweiten Befehl diese temporären Dateien löschst, bleiben die temporären Dateien trotzdem im Image (als Teil des ersten Layers).
Daher ist es wichtig, solche Befehle immer in einem zusammenzufassen.

RUN apt install <something> \
<some_other_thing>; \
rm <temp_files>

Um mehr Übersicht zu erhalten, können Befehle in mehrere Zeilen unterteilt werden, in dem (wie in bash üblich) ein Backslash angewendet wird. Mehrere Befehle können ebenfalls in einem Kommando verwendet werden, in dem Fall muss noch ein Semikolon zugefügt werden.

Hinweis: Alte Daten in alten Layern zu speichern hat nicht nur negative Auswirkungen auf die Größe, sondern auf die Verhaltensweise diverser Tools, die mit den Images arbeiten. Weiter unten scannen wir exemplarisch Images mit trivy auf Sicherheitslücken. Trivy scannt auch alte Image-Layer durch und kann somit Sicherheitslücken feststellen, selbst wenn die betroffene Komponente gar nicht im eigentlich laufenden Container verfügbar ist.

Caching während des Build-Vorgang

Alle Build-Tools unterstützen das Caching von Layern. Im simpelsten Fall: Wenn sich kein Layer verändert, “rennt” das Kommando docker build einfach durch und ist nach ein paar Sekunden fertig.

Während des Build Vorgangs wird das Ergebnis eines jedes einzelnen Kommandos (Layers) ausgegeben:

STEP 1: FROM docker.io/library/alpine:3.14.3
...
...
...
--> 998a9db9621
STEP 6: COPY createcerts.sh /home/appuser/app/tools/createcerts.sh
--> Using cache a46d829cccc1712bf25ea94dd837d25135f6d8ab3d81e40cd03b5044b9e3aaed
--> a46d829cccc
....
STEP 9: COMMIT ghcr.io/myimage/myimage:dev
--> 3979b3cb846

Ändert sich ein Layer, wird docker i.d.R. ab diesem Layer erneut bauen. Eine Änderung stellt docker wie folgt fest:

  • Die Quelldatei in einem ADD/COPY Kommando hat sich geändert
  • Der Inhalt eines RUN Kommandos hat sich geändert

Du wirst schnell feststellen, so einfach ist es dann doch nicht. Oft genug passiert es, dass docker einen Befehle neu ausführt, obwohl sich eigentlich nichts an diesem geändert hat. Grade das Caching ist ein Grund, warum es immer mehr Tools am Markt gibt, die sich speziell um das “Bauen” von Container Images drehen.

Tip: Hast du einen COPY/ADD/RUN Befehl, dessen Inhalt sich sehr häufig ändert, dann packe in möglichst ans Ende deines Scriptes um den Cache so viel wie möglich zu nutzen.

Dockerfile Zeile für Zeile

Folgend beschreibe ich dir nun Zeile für Zeile den Aufbau des Dockerfiles für das Base-Image. Das komplette Dockerfile kannst du hier herunterladen.

Image source

FROM docker.io/library/alpine:3.14.3

Es wird das “Official Image” von Dockerhub verwendet.

Verwende “voll qualifizerte” Name

Ein wichtiger Security Aspekt ist es, immer den Voll-qualifizieren Namen zu verwenden. Nutzt du hier einfach nur “FROM alpine:3.14.3“, würdest du grundsätzlich das gleiche Image erhalten. Allerdings sind diverse Build-Tools dadurch kompromittierbar.

Beispiel:

Nutzt du podman als Buildtool, kann der Name "ghcr.io/frickeldave/fd_alpine:3.15.0” durch “fd_alpine:3.15.0” abgekürzt werden. Dabei durchsucht podman die definierten Registries in “/etc/containers/registries.conf” oder “$HOME/.config/containers/registries.conf“. Die Notation in der “registries.conf” lautet wie folgt: 

unqualified-search-registries = ['ghcr.io', 'quay.io', 'docker.io']

Hierbei werden die Registries von links nach rechts ausgewertet. Wird das angeforderte Image nicht in “ghcr.io” gefunden, wird versucht, dieses von “quay.io” zu laden, als letztes von “docker.io“. Wird nun der Kurzname beim “pull” verwendet, kann ein Angreifer die Reihenfolge der Registries in “$HOME/.config/containers/registries.conf” umdrehen (docker.io nach vorne) und ein kompromittiertes Images unter dem Namen “fd_alpine” in “docker.io” ablegen.
Jetzt könntest du sagen, interessiert mich nicht, ich nutze ja kein podman. Das ist wiederum ein Thema, welches sich vollständig deiner Kontrolle entzieht. Du weißt einfach nie, welche Tools deine Kollegen in Ihren Entwicklungsumgebungen verwenden. Noch undurchsichtiger wird die Lage, wenn Dienstleister mit Ihrem eigenen Equipment ins Spiel kommen.

Explizite Versionen

Ein weiterer wichtiger Punkt ist, immer explizite Versionen zu verwenden. Hier hast du die bestmögliche Kontrolle über deine Images, ohne wesentlich mehr Aufwand betreiben zu müssen. Weiter unten erkläre ich Dir, wie du verhinderst, dass ein veraltetes (und somit potentiell gefährdetes Images) in deine Umgebung verteilt wird.

Build-Argumente

ARG fd_builddate

Build Argumente können genutzt werden, um Werte von außerhalb in den docker Build reinzufüttern, oder statisch zu definieren (in diesem Fall zum Beispiel ARG fd_myapp_version=23.3.2 um die URL zum Download einer Anwendung zu bauen).

Der Wert des Argumentes wird beim docker build Kommando übergeben:
docker build --build-arg fd_builddate=$(date -u +'%Y-%m-%dT%H:%M:%SZ') -t myregistry/myimage:latest .

Labels

Labels erweitern das Docker-Image um diverse Meta-Informationen und helfen Ordnung zu halten. Die hier gezeigten OCI Labels (org.opencontainers....) haben den Vorteil, dass sie schon von verschiedenen Registries (so auch die github package registry ghcr.io) verwendet werden um zum Beispiel Informationen direkt in der UI anzuzeigen.

LABEL org.opencontainers.image.authors="David Koenig mymailaddress@frickeldave.de"
LABEL org.opencontainers.image.created="2021-11-26"

LABEL org.opencontainers.image.version="3.14.3"
LABEL org.opencontainers.image.url="https://github.com/Frick...Base/alpine"
LABEL org.opencontainers.image.documentation="https://github.com/Frick...Base/alpine/README.md"
LABEL org.opencontainers.image.source="https://github.com/Frick...Base/alpine"
LABEL org.opencontainers.image.description "This is the base image for the ... environment."

LABEL de.frickeldave.containers.builddate=$fd_builddate
LABEL de.frickeldave.containers.buildnumber=$fd_buildnumber

Für die Labels gelten folgende Regeln:

  • Label mit Leerstellen werden mit ” umschlossen. Mehrere Zeilen können mit \ gesetzt werden.
  • Labels von Parent-Images werden vererbt
  • Labels mit gleichen Namen in Child-Images überschreiben die Werte der Labels aus den Parent Images.

Der Inhalt eines Labels kann mit folgendem Befehl abgerufen werden:

docker inspect -f '{{ index .Config.Labels "org.opencontainers.image.version" }}' myregistry/myimage:latest

Du kannst neben den hier gezeigten, auch beliebige weitere Labels pflegen (Bsp. Ticket ID, git reference, …). Zudem nutzen auch diverse Tools die Labels. So kann der Reverse Proxy “Traefik” vollautomatisch durch die Labels in einem Docker Container konfiguriert werden. Wie du sicher schon bemerkt hast, gibt es hier auch das Label “de.frickeldave.container.builddate” und “....buildnumber” in das der Inhalt der “fd_builddate/fd_buildnumber” Argumente aufgenommen wird. Auch diese Labels werden genutzt, um Informationen in der CI Strecke auszulesen und Dinge entsprechend zu automatisieren.

User anlegen

Nun wird ein User angelegt, der genutzt wird um die Anwendung auszuführen.

RUN adduser -D -h "/home/appuser" -u 50000 -g 50000 -s /bin/sh appuser; \
mkdir /home/appuser/data; \
mkdir /home/appuser/data/certificates; \
mkdir /home/appuser/app; \
mkdir /home/appuser/app/tools

Verzeichnisse

Hier werden die Unterverzeichnisse “data” und “app” im Homeverzeichnis des Users angelegt. Im “app” Verzeichnis werden alle statischen Applikationsdateien abgelegt, im “data” Verzeichnis alle Daten, die persistent gespeichert werden müssen (und später in ein Volume gemountet werden).

Das Verzeichnis “certificates” im “data” Verzeichnis wird schon einmal vorweg angelegt und bildet die standardisierte Dateiablage für Zertifikate und Keystores. Das “tools” Verzeichnis im “app” Verzeichnis nimmt alle Tools auf, die du in deiner Umgebung benötigst. Ich werde weiter unten darauf noch zurückkommen.

Die User ID

Die Cointainer Runtime Engines mappen i.d.R. die lokale User-ID auf die ID im Container. Würde man also einen User mit der ID 1000 nutzen um die Anwendung auszuführen, würde die Anwendung höchstwahrscheinlich im Kontext des root-users laufen. Das ist aber auf alle Fälle zu vermeiden. Daher wird eine sehr hohe User-Id verwendet um ein “versehentliches Mapping” zu vermeiden.

Ich lege auf meinen Host-Systemen (wenn es denn ein statischer Host ist) auf dem ich die Anwendung teste, gerne einen (gehärteten) User mit der ID 50000 an, in dessen Kontext die Container gestartet werden. Wichtig zu wissen ist nämlich folgendes: Auch lokale Verzeichnisse und Dateien die in den Container gemountet werden sollen, erhalten im Container die ID, die auch auf dem Host die Berechtigungen auf die Objekte hat. Legst du auf dem Host den Runtime-User identisch zum User im Container an, vermeidest du Probleme beim setzen der Berechtigungen.

Hinweis: Ich will nochmal erwähnen, es geht hier um eine Testumgebung. In einer produktiven Umgebung ist es natürlich Zielsetzung, dass der Runtime-User eben keinen Zugriff auf Dateien des Hostsystems erhält.

Shell

Als default Shell wird “sh” festgelegt, da speziell beim Einsatz von Alpine Linux eine Bash nicht im default zur Verfügung steht. Dieses solltest du auch nicht installieren, außer die Anwendung benötigt sie explizit. Ein Beispiel dafür ist Jenkins.

Setzen der Berechtigungen

Ich habe im vorigen Befehl beim Anlegen des Benutzer die Verzeichnisse angelegt. Du musst allerdings immer bedenken, es ist der “root” user der diese Verzeichnisse während des builds anlegt. Der “appuser” wird auf diese also keine Berechtigungen erhalten. Diese musst du explizit setzen, was wir mit folgendem Befehl erreichen:

RUN chown -R appuser:appuser /home/appuser/data; \
chown -R appuser:appuser /home/appuser/app

Update des System

Ein paar zusätzliche Pakete sind auf jeden Fall notwendig und natürlich wollen wir das System auf dem aktuellsten Stand heben.

RUN apk update; \
apk --no-cache add jq \
curl \
ca-certificates \
openssl; \
rm -rf /var/lib/apt/lists/; \

rm -rf /var/cache/apk/; \
update-ca-certificates --fresh 2>/dev/null || true

Im ersten Schritt wird das System aktualisiert. Danach installiere ich die folgenden Tools

  • “jq” – Zum interpretieren von JSON Dateien in der Shell
  • “curl” – Mein Lieblings-Download tool, welches ich in vererbtem Images benötige
  • “ca-certificates” – Zum aktualisieren der Zertifikate
  • “openssl” – Zum erstellen selbst-signierter Zertifikate

Anschließend werden alle durch “apt” ggf. zwischengespeicherten Daten gelöscht und sämtliche Zertifikate des Systems auf den neuesten Stand gebracht.

Self-signed certificates

Ich habe mir ein Script gebaut, das es mir ermöglicht, (SSL)-Zertifikate selber zu erstellen. Dies nutze ich für lokale Tests. Eine kurze Erklärung zum “warum”: In vielen Unternehmen wird die Verschlüsselung in lokalen Umgebungen einfach weggelassen, wenn eine Anwendung getestet wird. Die böse Überraschung folgt dann beim Deployment in eine produktive Umgebung: Irgendetwas funktioniert nicht, weil in dem Moment die Unternehmenszertifikate eingebunden werden und sich das Verhalten der Anwendung oder gewisse URLs ändern. Aus diesem Grund lege ich Wert darauf, schon bei den ersten Tests auf der lokalen Entwicklerumgebung möglichst “nah an der Produktion” zu arbeiten, die schließt auch die Nutzung von SSL mit ein. Das Script wird von einer Remote Location wie folgt eingebunden:

RUN curl https://raw.githubusercontent.com/Frickeldave/ScriptCollection/master/bash/create-self-signed-certificates/createcerts.sh --output /home/appuser/app/tools/createcerts.sh

Tip: Das Script zum erstellen von SSL Zertifikaten kann genau so gut mit einem “ADD” oder “COPY” Befehl integriert und mit in das Git-Repository des Basisimage eingebunden werden. Durch die Speicherung im Basisimage ist es dann ja auch in allen weiteren Images verfügbar. Allerdings kann es Umstände geben, in denen ich bewusst NICHT vom Basis-Image erbe, aber trotzdem solche Tools benötige (weiteres dazu siehe weiter unten am Beispiel: RabbitMQ). Aus diesem Grund speichere ich solche externen Tools gerne in einem unabhängigen Repository und lade sie von dort nach.

Das script kann später während des Starts des Containers aufgerufen werden und wird vollständig mittels Variablen konfiguriert. Mehr dazu hier.

Setzen der Berechtigungen Teil 2

Nicht vergessen: Auch Dateien die du nach dem letzten Setzen der Berechtigungen in das Image kopierst, musst du erneut berechtigen. Dies kannst du wie oben gezeigt machen.

Nutzt du den COPY Befehl im Dockerfile, kannst du das Setzen der Berechtigungen auch direkt mit dem Befehl erledigen.

COPY --chown=${user}:${user} /home/appuser/app/tools/createcerts.sh

Ansonsten wie gehabt:

chown -R appuser:appuser/home/appuser/app/tools/createcerts.sh

Arbeiten in vererbten Images

Weiter geht es mit vererbten Images. Im Prinzip geht es hier hauptsächlich um das Basis-Image deiner Struktur. Aber um dieses wirklich gut zu bauen, solltest du immer einige “Child-Images” mit bauen und auch in deiner Verantwortung halten. Dies hat mehrere Grunde:

  • Führst du Änderungen an einem Basis Image aus, kannst du die Auswirkungen am child-image direkt überprüfen
  • Du kannst Blueprints für die Gestaltung weitere Child-Images erstellen und herausgeben
  • Gerade Images des Intermediate Layer sind von Seiten der Sicherheitsbetrachtung durchaus sehr relevant

Hier muss unterschieden werden. Wie arbeite ich in einem Image das direkt vom Basis Image erbt und wie arbeite ich in einem Image, das 2 Schichten unterhalb in der Image Hirarchie steht und mehrere Userwechsel erfordert.

Userswitch in Image Hirarchien

In nebenstehendem Schaubild wird das Alpine Image gebaut und kein “USER” Kommando eingebaut, welches den Standard-User auf appuser wechselt.

Tip: Wer mag, kann dies übrigens tun, um ganz sicher zu gehen, dass nicht “aus Versehen” ein Image mit “root” Rechten betrieben wird.

Das gleiche geschieht auch im Powershell Image. Auch dieses Image wird i.d.R. nicht einfach verwendet.

Du kannst also sowohl im “Powershell” als auch im “Powershell App” Image einfach administrative Tasks (wie z.B. die Installation eines packages” ausführen.

Bei “Nginx” Image und darauf basierenden Anwendung sieht das schon anders aus. Der Nginx wird ggf. auch als Reverse Proxy” ohne weitere Konfiguration verwendet. Daher schließen wir das Dockerfile mit dem folgende Befehl ab:

USER appuser

Damit wird der Standarduser eingestellt, der beim instanziieren des Containers verwendet wird. Das komplette Dockerfile für den nginx Server findest du übrigens hier.

Dieser User wird aber verwendet, wenn ein untergeordnetes Dockerfile dieses Image verwendet. Werden nun Befehle ausgeführt die administrative Rechte verlangen, schlagen diese fehl. Lösung ist, erst den User auf root zu wechseln, und am Ende des Anwendungsimages wieder den appuser zu aktivieren.

USER root
RUN apk add ....
USER appuser

Verschlüsselung/ Zertifikate

Jede Anwendung sollte in jeder Stage des Entwicklungsprozesses (lokale Entwicklungsumgebung, gemeinsame Entwicklungsumgebung, Integrationstest, Produktion) die gleichen Verschlüsselungsverfahren nutzen. Ziel ist es, möglichst früh im Build-Workflow festzustellen, wenn etwas nicht funktioniert.

Aus diesem Grund ist in das Alpine-Base image ein Script zur Erzeugung selbst-signierter Zertifkate integriert. Dieses Script nimmt folgende Variablen entgegen:

  • CRT_VALIDITY – Die Gültigkeitsdauer des Zeritifkates (Bsp: 3650 für 10 Jahre)
  • CRT_C – Zweistelliger contrycode (Bsp. DE für Deutschland)
  • CRT_S – Das Bundesland (Bsp. Bavaria)
  • CRT_L – Die Stadt (Bsp. Ismaning)
  • CRT_OU – Die Abteilung (Bsp. IT)
  • CRT_CN – Der Name des Rechners (Bsp. myapp)
  • CRT_ALTNAME – Ein alternativer Name des Rechner oder die IP Adresse, wenn diese zum erreichen des Systems verwendet werden soll (Bsp. myapp.frickeldave.bavarian)
  • CRT_ALTTYPE – Der typ des alternativen Namen (Bsp. IP, DNS)

Nach dem Setzen der Variablen wird nur noch createcerts.sh aufgerufen um in das Verzeichnis /home/appuser/data/certificates die Zertifikatsdateien (key.key und cer.crt) zu schreiben. Diese Variablen können natürlich auch via “docker compose” (oder einem bel. anderen Tool befüllt werden.

Hinweis: Allerspätestens in der produktiven Umgebung muss auf “richtige” Zertifikate geschwenkt werden.

Scan

Es gibt grundsätzlich 3 Dinge auf die ein Anwendungsimage gescanned werden sollte.

  • Vulnerabilities
    Vulnerabilities werden oft auch mit CVE (Common Vulnerabilities and Exposures) abgekürzt. Dies stellt ein einheitliches Namenssystem für Sicherheitslücken und Schwachstellen dar. CVEs werden von allen namhaften Herstellern gepflegt, somit soll die Notwendigkeit entfallen, für jeden Herstellen unterschiedliche Systeme pflegen zu müssen. Dabei wird jede Schwachstelle mit einer eindeutigen Nummer versehen (CVE-yyyy-xxxxxxxx). Jede Sicherheitslücke wird zudem mit dem CVSS (Common vulnernability scoring system) bewertet. Seit der Version 3.0 des CVSS stehen die Schlüsselwörter “KEIN“, “NIEDRIG“, “MITTEL“, “HOCH“, “KRITISCH” zur Verfügung um den Schweregrad einer Sicherheitslücke zu benennen.
  • Code security
    Ein Code-Security Scan analysiert Code auf Schwachstellen. So werden simple Schwachstellen, wie das statische Speichern von Kennwörter oder SQL Injections im besten Fall noch vor dem push in das Git Repository erkannt und eliminiert. Spätestens aber ein Gate in der Build Pipeline sollte die Weitergabe des betroffenen Code verhindern.
  • Lizenzen
    Werden externe Bibliotheken in die eigene Software eingebunden, ist es wichtig deren Lizenzart zu kennen. Manche Bibliotheken können beliebig eingebunden und verwendet werden, bei machen liegt eine “Copy-Left” Lizenz vor. Die bedeutet, die “Bearbeitung” des Codes muss unter der gleichen Lizenz erfolgen. Was genau unter “Bearbeitung” zu verstehen ist, erschließt sich oft erst, durch genaue Studie der Lizenzbedingungen (durch einen Juristen). An dieser Stellen setzen Scan Tools an, die die exakten Gegebenheiten der Lizenzmodelle kennen und entsprechende Lücken aufweisen.
    Eine hervorragende Erklärung dazu hat Ookam Software in Ihrem Blog veröffentlicht.

Für alle Tools, die in irgendeiner Art und Weise “Code” scannen gilt: Das reine Scan Ergebnis sagt erst einmal gar nichts aus. Es kann zu negativen Scan Ergebnissen kommen, die getrost in eine ignore-Liste aufgenommen werden können, genauso gut ist es möglich, das ein Scanner bei einer kritischen Lücke gar nicht anschlägt. Ich zeige Dir hier beispielhaft, wie du die scan-tools für alle 3 Bereiche implementieren kannst. Diese Beispiele sind definitiv keine explizite Empfehlung für die Implementierung einer der Tools in deiner Umgebung.

Vulnerability Scan mit trivy

Trivy von aquasecurity besticht durch sein wirklich einfaches handling und die Commandline-basierte Ausführung. Dies ermöglicht die simple Integration in CI Pipelines.

Folgend findest du ein Beispiel für einen scan auf einer veralteten Version von Keycloak. Hier wird definiert, dass der “Exit Code” auf “1” gesetzt wird, wenn ein CVE mit dem Score “HIGH” aufgetreten ist.

appuser@docker:$ trivy --exit-code 1 --severity HIGH --no-progress ghcr.io/frickeldave/fd_keycloak:latest
2021-11-30T18:24:27.994+0100 INFO Detected OS: alpine
2021-11-30T18:24:27.994+0100 WARN This OS version is not on the EOL list: alpine 3.15
2021-11-30T18:24:27.994+0100 INFO Detecting Alpine vulnerabilities…
2021-11-30T18:24:28.002+0100 INFO Number of language-specific files: 1
2021-11-30T18:24:28.003+0100 INFO Detecting jar vulnerabilities…
2021-11-30T18:24:28.053+0100 WARN This OS version is no longer supported by the distribution: alpine 3.15.0
2021-11-30T18:24:28.053+0100 WARN The vulnerability detection may be insufficient because security updates are not provided

ghcr.io/frickeldave/fd_keycloak:latest (alpine 3.15.0)
Total: 0 (HIGH: 0)

Java (jar)
Total: 62 (HIGH: 62)
LIBRARY | VULNERABILITY ID | SEVERITY | INSTALLED VERSION | FIXED VERSION | TITLE
xyz | CVE-2020-10672 | HIGH | 2.9.10.1 | 2.9.10.4 | xyz: mishandles ...

WIP: Code security scan mit xyz

WIP: License scan mit xyz

WIP: Signierung von Images

Weitere Build-Tools rund um den Bau von Containern

Weitere Tools und Technologien verändern die Art und Weise, wie Images gebaut werden ständig und es ist extrem schwer da die Übersicht zu behalten. Dieses Kapitel soll ein wenig Licht ins Dunkel bringen und Dir auch zeigen, dass es noch komplett andere Wege gibt, Container Images zu bauen und zu verwalten. Aber lass dich dadurch auch nicht aus dem Konzept bringen. Du verbaust Dir nichts, wenn du einfach mit docker anfängst, Container sind mittlerweile ein Standard und toolübergreifend verwendbar.

  • docker-compose
    Eigentlich ein Tool um mehrere Container in einer zentralen Daten zu konfigurieren und diese auszuführen (Komposition). Tatächlich können auch build-Anweisungen in docker-compose hinterlegt werden und darüber der build gestartet werden. Mittlerweile ist docker-compose kein eigenständiges Tool mehr, sondern in die Docker-CLI integriert (Beispiel docker compose up).
  • Cloud-Native Images ohne Dockerfile
    Mit der Hilfe der Cloud Native Build Packs und Paketo.io wird es möglich, Docker Images direkt aus dem Source Code heraus zu erzeugen und somit Dockerfiles komplett überflüssig zu machen. Eine interessante Artikel dazu hat heise mit Fokus auf Spring-Boot herausgebracht.
  • buildah
    Buildah” bietet die Möglichkeit, Container-Images von der Kommndozeile (also ohne Dockerfile) aus zu bauen und kommt dafür auch ohne daemon aus.
  • podman
    Podman ist eine Build- und Runtime Umgebung die den Anspruch hat, möglichst kompatibel zur Docker-CLI zu sein. Darüber hinaus unterstützt podman auch die Erstellung von Pods, also der Gruppierung von Containern ähnlich wie k8s. Podman unterstützt mittlerweile auch docker-compose Dateien.
  • dive
    Dive ist ein Tool zur Analyse von Container-Layern.
  • ….

Anwendungs-Design für Container Umgebungen

Leider ist es nicht möglich, jede beliebige Anwendung in einen Container zu verpacken. Einige Voraussetzungen muss die Anwendung erfüllen: Auf die wichtigsten will ich hier eingehen.

Single process design

Es gibt eine einfache Grundregel, bei der Auswahl und Entwicklung von Anwendungen:
Jeder Container führt exakt einen Prozess aus. Müssen mehrere ausführbare Prozesse parallel gestartet werden um die Anwendung bereitzustellen, müssen diese auch auf mehrere Container verteilt und über entsprechenden Kompositionstools (docker-compose, podman, k8s, …) gestartet werden. Ist dies nicht möglich, gibt es zwar Workarounds, die sogar docker selbst in Ihrer Dokumentation beschreibt. Ich würde aber grundsätzlich von einem solchen Vorgehen abraten und noch mal auf die Suche nach Alternativen gehen.

Logging

Ein Aufruf von “docker logs <container id>” zeigt mir normalerweise sofort an, was im Container passiert. Das funktioniert, weil im container-Umfeld die Anwendungen nicht mehr in Logdateien sondern nach stdout schreiben.

So simpel dieser Fakt zu sein scheint, so viel Arbeit erspart er mir aber auch. Warum? Hier einige Gesichtspunkte die mir spontan einfallen:

  • Ich muss mich nicht mehr bei jeder Anwendung darum kümmern, wo Log Dateien hingeschrieben werden müssen. Pfadermittlung, Berechtigungsprüfung, etc. kann alles aus meinem Code entfernt werden.
  • Es muss keine Log-Rotation mehr (in der Anwendung) implementiert werden.
  • Keine Zugriffskonflikte mehr auf Dateien.
  • Auch mega-krasse individuelle Log-Datenbanken-Anwendungs-Implementierungen können entfallen.
  • Keine MessageQueue fürs Logging mehr (Zumindestens nicht in der Anwendung).
  • Ein Tool kann die Logs der Container Engine zentral abgreifen und ebenso zentral speichern.
  • Keine DSGVO Katastropheneinätze mehr, weil ein Benutzer seine persönliche Daten einsehen will und keiner weiß in welchen Log Dateien benutzerbezogene Infos stehen.

So schön das auch klingt, wie immer gibt es da ein paar “abers” und man fragt sich, wo denn die doch wichtigen Informationen in Zukunft persistent gespeichert werden?

Anwendungsdesign für stdout-Logging anpassen

Der erste und wichtigste Punkt ist natürlich: Schreibt meine Anwendung nach stdout, bzw. kann ich sie dahingehend konfigurieren? Ist dies nicht gegeben, solltest du sie nicht containerisieren und ein Redesign umgehend angehen. Es gibt tatsächlich auch Lösungen um Logs aus Dateien “containergerecht” aufzuarbeiten. Zu diesem Zweck wird neben dem Anwendungscontainer ein Sidecar Container hochgefahren, der in der Lage ist, die Log-Dateien auszulesen und seinerseits nach stdout zu schreiben. Ein gutes Beispiel wie so etwas umgesetzt werden kann, findest du hier.
Das ist aber wieder eine Komplexitätsstufe die du möglichst vermeiden solltest. Stehe ich vor Auswahl eines Produktes, ist dies ein Punkt den ich auf jeden Fall prüfe. Kann die Anwendung nicht nach stdout schreiben, fällt sie (für mich) ohne weitere Diskussion aus dem Raster.

Container Runtime konfigurieren

Startest du nun X Container die fleissig nach stdout loggen, wirst du wahrscheinlich relativ schnell vor dem Problem vollgemüllter Festplatten stehen. Daher bieten die Runtimes Möglichkeiten, das Logging Verhalten zu beeinflussen. Im Falle von Docker kannst du relativ einfach den Docker Daemon entsprechend den eigenen Wünschen konfigurieren. Genaue Infos bietet Docker in seiner Dokumentation. Bei anderen Tools gibt es ganz ähnliche Konfigurationsoptionen.

Logging Infrastruktur

In manchen Fällen mag es reichen, Log Informationen bei Bedarf lokal abzurufen. In der Regel gibt es aber Gründe, Logs zentral aufzubereiten und persistent zu speichern. Docker bietet hier sogenannte “Logging Treiber”. Diese kümmern sich darum, Log-Informationen der Container aufzubereiten und an ein Logging-Backend zu senden. Die verfügbaren Backends kannst du hier einsehen. Auch die Entwicklung eines eigenen Treibers hat sich in der Vergangenheit stark vereinfacht, was die Anbindung von Custom-Log-Implementierungen möglich macht.

Neben Docker gibt es aber auch viele weitere Runtime-Umgebungen, ganz vorne mit dabei natürlich die k8s Derivate. Hier kommst du um die Implementierung einer vollständigen Log-Infrastruktur und Erarbeitung entsprechender Konzepte nicht mehr herum. Darauf genau einzugehen, sprengt definitiv den Rahmen dieses Artikels. Daher der Hinweis: Kümmere dich frühzeitig um ein Logging Konzept ;-).

Testen (WIP)

Empfehlung: Nutz docker-compose, weil da alle Variablen definiert werden

Beispiel single container:

docker compose run –entrypoint sh mariadb

Beispiel multiple container:

entrypoint: /bin/sh -c ‘echo “overridden entrypoint” > /tmp/test; tail -f /tmp/test;’

docker compose up -d

docker compose logs -f

docker compose exec keycloak sh

docker compose exec -u root keycloak sh

Schreibe einen Kommentar

WordPress Cookie Hinweis von Real Cookie Banner