Dockerfile für Code Engine schreiben

Bevor Sie Ihren Code in ein Container-Image bauen, sollten Sie einige grundlegende Informationen über die Funktionsweise eines Docker-Builds in IBM Cloud® Code Engine lernen. Anschließend erhalten Sie Informationen zu einigen bewährten Verfahren für Ihre Dockerfile, damit Sie diese Zielvorgaben erreichen. Schwerpunkt dieser Beispiele ist die Verringerung der Imagegröße, die Verbesserung der Interoperabilität mit Code Engine-Anwendungen und die Ausführung von Containern als Benutzer ohne Rootberechtigung.

Beim Erstellen Ihrer Buildkonfiguration entscheiden Sie, welche der beiden verfügbaren Strategien verwendet werden soll.

  1. Cloudnative Buildpacks überprüfen Ihren Quellcode und erkennen, auf welcher Laufzeitumgebung Ihr Code basiert und wie aus Ihren Quellen ein Container-Image erstellt wird. Dies wird bei mehreren Programmierumgebungen unterstützt. Die Liste der unterstützten Umgebungen finden Sie im Abschnitt Buildstrategie wählen.

  2. Ein Docker-Build erstellt einen Container auf der Grundlage der zugehörigen Beschreibung in einer Dockerfile. Die Dockerfile wird anschließend zum Erstellen des Containers zusammen mit Ihrem Quellcode festgeschrieben.

Sie können für Ihren Build zwar eine dieser beiden Strategien verwenden, beispielsweise in den folgenden Fällen kann es aber sinnvoll sein, die Dockerfile-Strategie zu wählen:

  • Ihre Programmierumgebung wird von Buildpacks nicht unterstützt.
  • Ihr Projektbuild muss zusätzliche Pakete im Container installieren.

Grundlegende Informationen zu Dockerfiles

Eine Dockerfile beschreibt, wie ein Container erstellt wird. In Ihrer Dockerfile wählen Sie ein Basisimage aus, das die erforderlichen Tools enthält, die Sie während des Builds und der Laufzeit benötigen. Sie können Dateien aus einem Build-Kontext in das Image kopieren, Befehle ausführen, das Laufzeitverhalten wie Umgebungsvariablen, verfügbare Ports definieren und ENTRYPOINT festlegen. ENTRYPOINT wird durch den Befehl festgelegt, der beim Starten des Containers aufgerufen wird. Weitere Informationen darüber, wie Dockerfile-Anweisungen angegeben werden können, finden Sie unter Dockerfile-Referenz.

In einem Code Engine-Build definieren Sie eine Quelle, die auf ein Git-Repository verweist. Der Kontext, der für den Docker-Build verfügbar ist, ist standardmäßig das Stammverzeichnis Ihres Git-Repositorys. Wenn Ihr Repository beispielsweise ein Verzeichnis namens src enthält, können Sie mit der Anweisung COPY in der Dockerfile dieses Verzeichnis in Ihr Image kopieren. Beispiel:

COPY src /app/src

Geben Sie zum Kopieren des gesamten Git-Repositorys die folgende Anweisung COPY an:

COPY . /app/src

Wenn Sie das gesamte Repository Git kopieren, aber einige Dateien ausschließen möchten, z. B. die README.md des Repositorys, dann können Sie eine Datei .dockerignore hinzufügen. Verwenden Sie die gleiche Datei, um auch die Dateien und Verzeichnisse zu ignorieren, die Sie in Ihrer .gitignore-Datei angeben. Wenn Sie dieselbe Datei verwenden, stellen Sie sicher, dass bei einem Build, den Sie lokal ausführen, dieselbe Gruppe von Dateien zur Verfügung steht wie bei dem Build in Code Engine.

Kopieren Sie die Anwendungsdateien stets in ein Unterverzeichnis des Stammverzeichnisses (/) und nicht direkt in das Stammverzeichnis, um Konflikte mit Betriebssystemdateien zu vermeiden. Verwenden Sie bei der Benennung des Anwendungsverzeichnisses keinen Namen, der unter UNIX-basierten Betriebssystemen, Kubernetes oder Code Engine reserviert ist, z. B. /bin, /dev, /etc, /lib, /proc, /run, /sys, /usr, /var oder /workspace. Es hat sich bewährt, den Namen /app für das Anwendungsverzeichnis zu verwenden.

Wenn Ihr Quellcode-Repository die Quellen für verschiedene Anwendungen enthält, die in Verzeichnissen organisiert sind, ähnlich wie das Code Engine samples Repository, dann können Sie ein Unterverzeichnis als Kontext verwenden. Geben Sie im Befehl ibmcloud ce build create Unterverzeichnisse mithilfe der Option --context-dir an.

Wenn sich Ihre Dockerfile nicht im Kontextverzeichnis befindet, können Sie mit dem Argument --dockerfile darauf verweisen.

Um einen Docker Build auf Ihrem lokalen System zu testen, bevor Sie ihn in Code Engine erstellen, können Sie Docker Desktop verwenden.

Größe eines Container-Image reduzieren

Eine Größenverringerung für ein Container-Image ist in mehrerlei Hinsicht vorteilhaft.

  • Zum Speichern des Images in der Container-Registry wird weniger Speicherbereich benötigt. Sie können Kontingente für andere Images aufheben und Geld sparen.
  • Manchmal benötigt die Buildausführung weniger Zeit, da ein kleineres Image schneller in die Container-Registry übertragen werden kann als ein viel größeres Image. Auch hier sparen Sie Geld.
  • Die Anwendung oder der Job, die/der das Image verwendet, wird schneller gestartet, weil weniger Zeit zum Extrahieren des Images benötigt wird. Da die Ressourcen, die für die Ausführung Ihrer Anwendung bzw. Ihres Jobs erforderlich sind, während der Extraktion des Images reserviert werden, sparen Sie wiederum Kosten. Eine schnelle Initialisierungszeit ist besonders für Anwendungen in Code Engine von Bedeutung, weil sie selbst dann eine annehmbare Antwortzeit für die Anforderungen Ihrer Benutzer garantiert, wenn für die Anwendung ein Scale-down auf 0 durchgeführt wurde.

Beachten Sie einige der folgenden bewährten Verfahren, um den Umfang Ihres Builds zu verringern.

Kombinieren Sie mehrere Befehle in einer einzigen Anweisung RUN, um die Imagegröße zu reduzieren.

In diesem Beispiel müssen Sie Software im Container-Image installieren, z. B. Node.js. Verwenden Sie die Basisimages für Node.js, um eine Node.js-Anwendung zu erstellen.

Mit dem folgenden Dockerfile-Beispiel können Sie Node.js manuell installieren:

FROM ubuntu

RUN apt update
RUN apt upgrade -y
RUN apt install -y nodejs
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*

In diesem Beispiel dienen mehrere RUN-Anweisungen zur Aktualisierung, zum Upgrade und zur Installation von Node.js, zur Bereinigung und zur Leerung des Dateicache. Diese Sequenz von Anweisungen ist zwar korrekt, wird aber durch den Einsatz mehrerer Anweisungen RUN nicht optimal implementiert. Bei der Verarbeitung einer Dockerfile erstellt jede Anweisung in der Dockerfile eine Schicht. Jede Schicht enthält die erstellten oder aktualisierten Dateien sowie ebenfalls Informationen zu gelöschten Objekten. In einem Container-Image werden alle Schichten zusammengestellt. Falls eine Datei in einer Anweisung hinzugefügt und in einer zweiten Anweisung gelöscht wird, enthält das resultierende Image die Datei trotzdem in der Schicht für die erste Anweisung und kennzeichnet die Datei in der zweiten Schicht als gelöscht.

Geben Sie zum Einsparen von Plattenspeicherplatz eine einzige Anweisung RUN an, damit für diese Gruppe von Befehlen eine einzige Schicht erstellt wird:

FROM ubuntu

RUN apt update && apt upgrade -y && apt install -y nodejs && apt clean && rm -rf /var/lib/apt/lists/\*

Fügen Sie Zeilenumbrüche in Ihren Code ein, damit die Dockerfile nur einen einzigen Befehl pro Zeile enthält und lesbar bleibt:

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y nodejs && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

Mithilfe dieses Anweisungstyps können Sie die Größe des Container-Image von ca. 174 MB auf ungefähr 147 MB verringern. Beachten Sie, dass die Größen Ihrer Beispiele hiervon abweichen können, da sich das Ubuntu-Basisimage und das Node.js-Paket ändern können.

Dieses bewährte Verfahren lässt sich nicht nur bei Paketinstallationen, sondern auch bei ähnlichen Tasks anwenden. Auch der Download und die Extraktion eines Softwarepakets (hier ebenfalls Node.js) kann wie im folgenden Beispiel aussehen:

FROM ubuntu

RUN apt update
RUN apt upgrade -y
RUN apt install -y curl
RUN curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz
RUN mkdir /opt/node
RUN tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1
RUN rm /tmp/nodejs.tar.gz
RUN apt remove -y curl
RUN apt clean
RUN rm -rf /var/lib/apt/lists/\*

Der Befehl rm entfernt das heruntergeladene Paket nodejs, aber durch mehrere Anweisungen RUN werden auch hier separate Schichten erstellt. Außerdem wird für den Download des Pakets nodejs vorübergehend das Paket curl installiert. Das Paket 'curl' selbst wird später zwar entfernt, aber seine implizit installierten Abhängigkeiten sind weiterhin vorhanden. Eine bessere Dockerfile sieht wie im folgenden Beispiel aus:

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y curl && \
    curl https://nodejs.org/dist/v16.14.2/node-v16.14.2-linux-x64.tar.gz -o /tmp/nodejs.tar.gz && \
    mkdir /opt/node && \
    tar -xzf /tmp/nodejs.tar.gz -C /opt/node --strip 1 && \
    rm /tmp/nodejs.tar.gz && \
    apt remove -y curl && \
    apt auto-remove -y && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

Die zusammengehörigen Befehle sind in einer einzigen Anweisung RUN kombiniert und zum Entfernen der Abhängigkeiten wird der Befehl apt auto-remove hinzugefügt. Dieses Beispiel verringert die Imagegröße von ca. 222 MB auf ungefähr 162 MB.

Kompaktes Basisimage verwenden

In den obigen Beispielen wird Ubuntu als Basisimage eingesetzt. Dieses Image enthält zwar viele nützliche Dienstprogramme, aber mit zunehmender Anzahl der Dienstprogramme in einem Basisimage erhöht sich auch seine Größe. Darüberhinaus vergrößern Sie durch die Aufnahme weiterer Dienstprogramme die Möglichkeit, dass eine Sicherheitslücke festgestellt wird, die einen erneuten Build Ihres Image erforderlich macht. Zur Vermeidung dieser Probleme verwenden Sie ein kleineres Basisimage. Beispiel:

  • Alpine ist ein offizielles Docker-Image mit kompakter Größe. Für Programmierumgebungen wie Java oder Node.js finden Sie häufig Tags, die auf Alpine basieren.

  • Distroless-Images aus Google Container Tools enthalten überhaupt keine Betriebssystemtools, sondern nur die erforderliche Laufzeitumgebung für unterschiedliche Sprachen wie Java und Node.js.

Erstellen Sie zum Vergleich dieser Basisimages ein Container-Image, das ein Node.js-Programm ausführt, in einer Datei 'program.js'. Bei Ubuntu sieht die Dockerfile in etwa wie das folgende Beispiel aus:

FROM ubuntu

RUN \
    apt update && \
    apt upgrade -y && \
    apt install -y nodejs && \
    apt clean && \
    rm -rf /var/lib/apt/lists/\*

COPY program.js /app/program.js

WORKDIR /app

ENTRYPOINT ["node", "program.js"]

Bei Alpine wird das Image direkt aus Node.js verwendet:

FROM node:16-alpine

COPY program.js /app/program.js

WORKDIR /app

ENTRYPOINT ["node", "program.js"]

Verwenden Sie für Distroless das folgende Beispiel:

FROM gcr.io/distroless/nodejs:16

COPY program.js /app/program.js

WORKDIR /app

CMD ["program.js"]

Während das Image auf Ubuntu-Basis 147 MB groß ist, umfasst das auf Alpine basierende Image 90 MB und das auf Distroless basierende Image 94 MB.

Imagegröße durch Ausschluss von Quellen und Build-Tools verringern

In den obigen Beispielen auf Node.js-Basis wird eine einzige Quellendatei zum Container-Image hinzugefügt. Diese Beispiele können eine einzige Quellendatei verwenden, weil keine Kompilierung erforderlich ist. Muss jedoch eine Kompilierung erfolgen, verwenden Sie die erforderlichen Tools ausschließlich für den Build, beziehen Sie sie jedoch nicht in das resultierende Image ein. Als Beispiel wird eine Java-Anwendung verwendet, die Maven nutzt. Eine schlecht codierte Dockerfile sieht in etwa wie das folgende Beispiel aus:

FROM maven:3-jdk-11-openj9

WORKDIR /app
COPY . /app

RUN mvn package

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/target/java-application-1.0-SNAPSHOT-fat.jar"]

Das resultierende Container-Image enthält den gesamten Quellcode und alle temporären Dateien aus Maven (z. B. Artefaktcache, Klassendateien, die später in eine JAR-Datei gepackt werden) sowie das Maven-Buildtool. Zusätzlich ist ebenfalls Java Development Kit (JDK) enthalten, wenn zur Laufzeit eine viel kleinere JRE (Java Runtime Environment) benötigt wird. Die Imagegröße beträgt infolgedessen 466 MB.

Mit einem Feature namens 'mehrstufiger Build' können Sie das Image verkleinern. Jede Stufe in einem Docker-Build besitzt ein eigenes Basisimage und kann Befehle in ihrer Stufe ausführen. Am Ende wird ein endgültiges Image erstellt, in das die Artefakte aus den vorherigen Stufen kopiert werden. Für den Build eines Container-Image aus Quellcode wird häufig ein Muster mit zwei Stufen verwendet:

  1. Die Builder-Phase, die ein Basisimage verwendet, das alle erforderlichen Tools zum Kompilieren des Quellcodes in die Binärdatei für die Laufzeit enthält
  2. Die Laufzeitstufe verwendet ein Basisimage mit der Laufzeitumgebung, die für die Ausführung der Binärdatei benötigt wird. Die Binärdatei aus der Builderstufe wird in diese Stufe kopiert.

Für das Maven-Projekt sieht das Ergebnis ähnlich wie im folgenden Beispiel aus:

FROM maven:3-jdk-11-openj9 AS builder

WORKDIR /app
COPY . /app

RUN mvn package

FROM adoptopenjdk:11-jre-openj9

WORKDIR /app
COPY --from=builder /app/target/java-application-1.0-SNAPSHOT-fat.jar /app/java-application.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/java-application.jar"]

In diesem Beispiel verwendet die Builderstufe das Maven-Basisimage, um die JAR-Datei der Anwendung zu erstellen. Die Laufzeitstufe verwendet das kleinere JRE-Basisimage. Mit dem Befehl COPY --from=STAGE_NAME wird die JAR-Datei aus der Builderstufe in die Laufzeitstufe kopiert. Das resultierende Image hat eine Größe von 242 MB und spart etwa 48 %.

Dieses Muster kann auch bei anderen Programmiersprachen genutzt werden, wenn eine Kompilierung ausgeführt werden muss.

  • Node-Anwendungen, die einen Build erforderlich machen, z. B. einen Angular- oder React-Build. Hier sind das Builder- und Laufzeitbasisimage möglicherweise letzten Endes identisch (beide 'Node'), aber nicht alle Artefakte und Quellen für die Buildzeit müssen in das Laufzeitimage kopiert werden.
  • Jede Programmiersprache, von der Quellcode in einer nativen ausführbaren Datei kompiliert wird, die ohne eine Laufzeitumgebung ausgeführt wird (z. B 'Go' oder 'Rust').

Für Sprachen, die eine native ausführbare Datei erzeugen können und überhaupt keine Laufzeitumgebung benötigen, nutzen Sie für die Laufzeitstufe mit 'scratch' eine weitere Docker-Funktionalität. 'scratch' kann als Basis im Befehl FROM verwendet werden, ist jedoch kein endgültiges Container-Image. Es teilt Docker vielmehr mit, dass überhaupt kein Basisimage verwendet werden soll. Ohne Betriebssystemdateien aus einem Basisimage kann das resultierende Image mit einer einzigen Datei (Ihrer aus der Builderstufe kopierten Binärdatei) so wenig wie möglich enthalten. Je nach Programmiersprache und Code müssen Sie die Compileroptionen möglicherweise weiter anpassen, da Binärdateien unter Umständen auf einige vorhandene Betriebssystemdateien angewiesen sind.

Image bereinigen

Wenn Sie Ihre Dockerfile erstmalig entwickeln und Fehler bei ihrer Funktionsweise beheben, installieren Sie möglicherweise einige temporäre Tools oder wenden andere Workloads an, die im endgültigen Container-Image nicht benötigt werden. Durch das Entfernen dieser temporären Dateien und Workloads wird Ihr Image bereinigt, kompakt und sicherer.

Startzeit des Images verbessern

Ihr Container-Image erzielt nur dann eine maximale Effizienz, wenn es so schnell wie möglich gestartet wird. Bei Anwendungen ist die relevante Messgröße die Dauer, die benötigt wird, bis der HTTP-Endpunkt verfügbar und zur Annahme sowie Verarbeitung eingehender HTTP-Anforderungen bereit ist. Die Startgeschwindigkeit ist insbesondere bei Code Engine-Anwendungen wichtig, die auf Knative basieren. Solche Anwendungen können für Situationen ohne Datenverkehr mit einem Scale-down bis auf 0 aktive Instanzen konfiguriert sein, damit sie keine Ressourcen verbrauchen und keine Kosten erzeugen. Sobald eine Anforderung eintrifft, wird zur Verarbeitung der Anforderung eine Instanz gestartet. Der Container muss als Reaktion auf die neue Anforderung so schnell wie möglich gestartet werden.

Untersuchen Sie zur Verbesserung des Anwendungsstarts die Implementierung Ihrer Anwendung und ermitteln Sie, ob Sie Muster wie beispielsweise Folgende anwenden können:

  • Parallelverarbeitung von unabhängigen Initialisierungstasks, beispielsweise zum Herstellen einer Verbindung zu einer Datenbank und zum Lesen der Konfigurationsdatei für die Kommunikation mit einem Mail-Server aus Umgebungsvariablen.
  • Verzögerung von Initialisierungstasks, die zum Anwendungsstart nicht benötigt werden, und stattdessen Ausführung dieser Tasks beim erstmaligen Bedarf.

Außerdem können Sie ein häufig auftretendes Problem beim Implementieren einer Webanwendung vermeiden, die ein Framework wie beispielsweise Angular, React oder Vue nutzt. Alle diese Frameworks basieren auf Node.js mit NPM und beinhalten eine Befehlszeilenschnittstelle, die die Einrichtung eines Projekts vereinfachen kann. Eine React-Anwendung, die mit dem Befehl create-react-app erstellt wird, legt zum Beispiel eine package.json-Datei an, die einige vordefinierte Skripts enthält. Eines dieser Scripts hat den Namen start; es startet zusammen mit Ihrer Webanwendung einen Web-Server. Ihre Dockerfile kann ähnlich wie im folgenden Beispiel aussehen:

FROM nodejs:16-alpine

COPY . /app
WORKDIR /app

RUN npm install

EXPOSE 3000
ENTRYPOINT ["npm", "run", "start"]

Dieser Typ Dockerfile funktioniert zwar, ist jedoch nicht so schnell wie eine Kompilierung und ein anschließender Start der Anwendung bei jedem Aufruf des Befehls npm run start. Diese Verzögerung macht sich besonders bei Anwendungen mit einem nicht nur geringen Stichprobenumfang bemerkbar. Die richtige Lösung besteht darin, die Anwendung zur Buildzeit zu kompilieren und erst beim Start bereitzustellen.

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app

RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve

COPY --from=builder /app/build /app

EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Auch hier wird das zweistufige Muster mit Builder und Laufzeit verwendet. Außerdem können Sie feststellen, dass das aktualisierte Beispiel einen anderen Port (8080) verwendet. Dieses Beispiel funktioniert zwar auch mit jedem anderen Port, aber 8080 ist der Standardport für Code Engine-Anwendungen. Außerdem sind bei Verwendung des kompilierten Builds alle Quellen und Tools, die im node_modules installiert sind, nicht im endgültigen Container-Image enthalten, wodurch die Größe von 281 auf 97 MB reduziert wird.

Container als Benutzer ohne Rootberechtigung ausführen

Gut konzipierte Systeme verfolgen das Prinzip der niedrigstmöglichen Berechtigung, demzufolge eine Anwendung oder ein Benutzer ausschließlich die zur Ausführung einer bestimmten Aktion erforderlichen Zugriffsrechte erhält. In Code Engine führen Sie einen Anwendungsserver oder eine Stapellogik aus, die in der Regel keinen Verwaltungszugriff auf das System erfordert. Es darf daher im Container keine Ausführung als Rootbenutzer erfolgen. In der Praxis hat es sich bewährt, das Container-Image mit einem definierten Benutzer zu konfigurieren und als Benutzer ohne Rootberechtigung auszuführen. Für das vorherige Szenario bedeutet dies beispielsweise:

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app
RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve

COPY --from=builder /app/build /app

USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Die Dockerfile gibt mit dem Befehl USER an, dass eine Ausführung als Benutzer und Gruppe 1100 erfolgen soll. Zu beachten ist, dass von diesem Befehl nicht implizit ein benannter Benutzer und eine Gruppe im Container-Image erstellt werden. Normalerweise ist diese Struktur zulässig, aber wenn Ihre Anwendungslogik erfordert, dass der Benutzer und dessen Ausgangsverzeichnis vorhanden sind, müssen Sie den Benutzer und die Gruppe explizit erstellen:

FROM node:16-alpine AS builder

COPY . /app
WORKDIR /app
RUN npm install && npm run build

FROM node:16-alpine

RUN npm install -g serve && \
    addgroup nonroot --gid 1100 && \
    adduser nonroot --ingroup nonroot --uid 1100 --home /home/nonroot --disabled-password

COPY --from=builder /app/build /app

USER 1100:1100
EXPOSE 8080
ENTRYPOINT [ "serve", "--single", "--no-clipboard", "--listen", "8080", "/app" ]

Der Befehl RUN in der Laufzeitstufe wurde dahingehend erweitert, dass die Befehle addgroup und adduser zur Erstellung einer Gruppe und eines Benutzers mit einem Ausgangsverzeichnis aufgerufen werden.