Wie man das richtige Docker Base-Image für Python wählt

Wenn es um containerisierte Anwendungen geht, ist die Wahl des richtigen Base-Image – also des grundlegenden Images, auf dem Sie neue, angepasste Container-Images aufbauen – schon die halbe Miete. Das Base-Image spielt später eine entscheidende Rolle hinsichtlich der Sicherheit, der Leistung, der Wartbarkeit,… der Anwendung.

Um das zu erklären, zeigt dieser Artikel verschiedene Ansätze für die Auswahl eines Base-Images für eine Anwendung, die in Python, einer der beliebtesten Programmiersprachen, entwickelt wird. Wie wir sehen werden, sind Python-Anwendungen zwar mit praktisch jeder Art von Base-Image für Container kompatibel, aber je nach Ihren Anforderungen und Prioritäten kann ein bestimmtes Base-Image für Sie sinnvoller sein als ein anderes.

Warum die Wahl des richtigen Base-Images so wichtig ist

Bevor wir uns mit den Optionen für ein Base-Image für Python beschäftigen, sollte man zunächst die wichtigsten generellen Punkte besprechen, warum die Wahl des richtigen Base-Images so wichtig ist:

  • Performance: Die Gesamtgröße Ihres Base-Images beeinflusst, wie schnell das Image heruntergeladen werden kann. Außerdem wirkt sich die Menge an Code, die das Image beim Start des Containers ausführt, auf die Dauer aus, die Ihre Anwendung braucht, um zu starten. Beide Faktoren sind für die Gesamtperformance des Containers von zentraler Bedeutung.
  • Abhängigkeiten: Viele Anwendungen haben Abhängigkeiten, d. h. Libraries und anderen Code, den sie für ihre Funktion brauchen. Wenn diese Abhängigkeiten in das Basis-Image integriert sind, muss man nicht so viel Arbeit in die Konfiguration des Containers stecken oder ihn an seine Anwendung anpassen.
  • Updates: Natürlich sollte das Base Image immer auf dem neuesten Stand sein. Das ist einfacher, wenn Ihr Base Image auf einer Software-Plattform oder -Distribution basiert, die aktiv gepflegt und regelmäßig von Drittentwicklern gepatcht wird.
  • Sicherheit: Je mehr Code sich in Ihrem Base Image befindet, desto größer ist die Angriffsfläche für Ihren Container. Aus diesem Grund sind Base-Images sicherer, die nur den Code enthalten, der für die Ausführung Ihrer Anwendung unbedingt erforderlich ist, und nicht mehr.

Wie man sieht, gibt es einen gewissen Zielkonflikt zwischen den einzelnen Punkten. Basis-Images, die viele Bibliotheken enthalten, erfüllen wahrscheinlich eher die Abhängigkeiten Ihrer Anwendung. Allerdings können sie aus einer Sicherheitsperspektive auch riskanter sein, weil sie Bibliotheken enthalten könnten, die man eigentlich gar nicht braucht, und die daher die Anfälligkeit für potenzielle Sicherheitslücken erhöhen.

In Anbetracht dieser gegensätzlichen Gesichtspunkte, die es bei der Auswahl eines Basis-Images abzuwägen gilt, gibt es häufig nicht die eine ideale Lösung. Stattdessen muss man strategisch über die spezifischen Anforderungen der eigenen Anwendung und der Hosting-Umgebung nachdenken, bevor man sich auf ein bestimmtes Base Image festlegt.

Drei Optionen für ein Base-Image für Python

Als Beispiel dafür, wie unterschiedliche Prioritäten bei der Auswahl des Base-Images berücksichtigt werden können, stellen wir uns vor, dass wir eine Python-Anwendung haben, die wir in einem Container ausführen wollen. Als Beispiel nehmen wir eine sehr einfache “Hello World”-Anwendung, deren Quelltext nur aus dieser Zeile des Python-Quellcodes besteht:

print("Hello world!")

Wir nennen unsere Anwendung hello.py. Man kann sie auch auf der Kommandozeile ausführen, wenn man möchte, mit:

python hello.py

Die Ausgabe sollte natürlich lauten:

Hello world!

Aber wir wollen die Anwendung nicht direkt von der Kommandozeile aus starten. Wir wollen sie in einem Container ausführen. Und dafür müssen wir zuerst ein Docker-Image erstellen, um die Anwendung auszuführen.

Da Python eine plattformübergreifende Programmiersprache ist, die fast überall ausgeführt werden kann, kann man eine containerisierte Python-Anwendung mit praktisch jedem Base-Image erstellen. Das gewählte Base-Image hat jedoch Auswirkungen auf die Funktionsweise der Anwendung, die Wartungsfreundlichkeit, die Sicherheit und so weiter.

Schauen wir uns also drei verschiedene Möglichkeiten für Python-Base-Images an: Eine leichtgewichtige Linux-Distribution, eine Standard-Linux-Distribution und ein von Grund auf neu erstelltes minimales Base-Image.

1.Leichtgewichtige Linux-Base-Images für Python

Für eine einfache Python-Anwendung mit wenigen oder keinen Abhängigkeiten kann man ein Base-Image verwenden, das mit einer leichtgewichtigen Linux-Distribution erstellt wurde. Diese Art von Base-Image bietet eine minimalistische Linux-Umgebung, in der der Container ausgeführt werden kann. Das macht sie sicherer als eine ” schwergewichtigere” Umgebung, die mehr potenzielle Schwachstellen enthält.

Der Nachteil bei der Nutzung eines minimalistischen Base-Images ist, dass man, wenn die Anwendung Abhängigkeiten hat und diese Abhängigkeiten nicht in das Base-Image integriert sind (was wahrscheinlich nicht der Fall ist, wenn es sich um ein minimalistisches Image handelt), selbst mehr Code bei der Image-Erstellung schreiben muss.

Aber gehen wir einmal davon aus, dass unsere Anwendung keine Abhängigkeiten hat. Sie kann mit einem leichtgewichtigen Linux-Base-Image wie Alpine ausgeführt werden, das man kostenlos von Docker Hub beziehen kann. Das Einzige, was wir in diesem Fall zusätzlich zum Base-Image ergänzen müssen, ist ein Python-Interpreter, da Alpine nicht standardmäßig mit Python ausgeliefert wird.

Erstellen wir also eine Dockerdatei, die Alpine als Base-Image verwendet, Python installiert und dann unsere Anwendung ausführt. Das würde wie folgt aussehen:

FROM alpine
WORKDIR /usr/bin
ENV PYTHONUNBUFFERED=1
RUN apk add --update --no-cache python3 && ln -sf python3 /usr/bin/python
COPY hello.py /usr/bin/hello
CMD python /usr/bin/hello

Speichern wir diese Datei unter dem Namen Dockerfile und stellen wir sicher, dass unsere Python-Anwendung im gleichen Verzeichnis wie das Dockerfile gespeichert ist. Dann bauen wir den Container mit:

docker build -t hello .

Dadurch wird Docker angewiesen, den Container mit dem Tag “hello” zu benennen.

Um den Container zu starten, geben wir folgendes ein:

docker run -it hello

Auf dem CLI sollte “Hello world!” angezeigt werden.

Wenn Sie wissen möchten, wie groß Ihr neues Container-Image ist, führen Sie den Befehl aus:

docker image ls

Wenn Sie Alpine als Basis-Image verwendet haben, werden Sie sehen, dass die Größe des Container-Images relativ gering ist – etwa 52 Megabyte:

REPOSITORY     TAG       IMAGE ID       CREATED         SIZE
hello          latest    456fcecc53d7   7 minutes ago   51.7MB

2. Base-Image mit einem Standard-Linux für Python

Eine leichtgewichtige Linux-Distribution wie Alpine eignet sich bestens für eine einfache “Hello World”-Anwendung, die außer Python keine weiteren Abhängigkeiten hat. Wenn man allerdings eine komplexere Python-Anwendung mit vielen Abhängigkeiten hat, ist Alpine möglicherweise keine gute Wahl, weil man jedes Modul zusätzlich zum Base-Image installieren müsste.

Stattdessen sollten man besser eine Standard-Linux-Distribution, wie Ubuntu, verwenden. In diesem Fall kann man einen Container für die Python-Anwendung mit einem Dockerfile erstellen, das wie folgt aussieht (in diesem Beispiel verwenden wir das Ubuntu 20.04 Base-Image):

ROM ubuntu:20.04
RUN apt update
RUN apt install -y python3
COPY hello.py /usr/bin/hello.py
ENTRYPOINT [ "python3" ]
CMD ["/usr/bin/hello.py"]

Mit dem gleichen Befehl wie für das Alpine-Basis-Image kann nun ein Container auf der Grundlage dieses Dockerfiles erstellt werden:

docker build -t hello_ubuntu .

In diesem Fall nennen wir das Image “hello_ubuntu”, um es von dem Alpine-basierten Image zu unterscheiden.

Der neue Container kann mit dem folgenden Befehl gestartet werden:

docker run -it hello_ubuntu

Da wir ein Ubuntu-Basis-Image verwendet haben, beträgt die Gesamtgröße des Images in diesem Fall 147 Megabyte – ein ganzes Stück größer als unser Alpine-Image:

REPOSITORY      TAG       IMAGE ID       CREATED             SIZE
hello_ubuntu    latest    0ed400ca01b4   4 minutes ago       147MB

3. Neues eigenes minimalistisches Base-Image erstellen

Eine dritte Möglichkeit, eine Python-Anwendung zu containerisieren, besteht darin, ein komplett neu erstelltes Base-Image zu verwenden. Bei diesem Ansatz verwenden wir kein Base-Image eines Drittanbieters. Stattdessen erzeugen wir unser eigenes, individuelles Base-Image und erstellen dann ein Container-Image für unsere Anwendung unter Verwendung dieses Base-Images. (Technisch gesehen gibt es bei diesem Ansatz immer noch ein Basis-Image – das “Scratch”-Image von Docker -, aber dabei handelt es sich um ein spezifisches, extrem minimalistisches Image, dessen einziger Zweck darin besteht, eine leere Schablone für die Erstellung völlig neuer Basis-Images bereitzustellen.)

Der Vorteil der Nutzung eines komplett neu erstellten Base-Images besteht darin, dass der Container so klein und sicher wie überhaupt möglich ist. Der große Nachteil ist, dass die Erstellung und Aktualisierung des Containers mehr Aufwand erfordert, da man den Python-Code in eine statische Binärdatei kompilieren muss. Das ist nötig, weil man die Anwendung ausführen können muss, ohne dass ein Python-Interpreter im Container verfügbar ist.

Um den Python-Code in statischen Code zu kompilieren, installiert man zunächst cython (das Python-Code in C übersetzt):

sudo -H pip3 install cython

Dann generieren wir anhand des Python-Codes den C-Quellcode (hier verwenden wir das Beispielprogramm “hello.py”):

cython hello.py --embed

Schließlich kompilieren wir den Code mit gcc:

gcc -Os -I /usr/include/python2.7 -o hello hello.c -lpython2.7 -lpthread -lm -lutil -ldl

(Beachten Sie, dass Sie den vorangehenden Befehl möglicherweise ändern müssen, je nachdem, welche Python-Version Sie bei sich installiert haben).

Jetzt sollten Sie eine ausführbare Binärdatei namens hello haben, die man ohne den Python-Interpreter ausführen kann:

./hello

Nun können wir eine Dockerdatei erstellen, die einen Container für die Ausführung dieser Binärdatei definiert:

FROM scratch
ADD hello /
CMD ["/hello"]

Erstellen und starten Sie den Container mit Befehlen wie:

docker build -t hello_scratch
docker run --it hello_scratch
Wenn man die Größe dieses Containers überprüft, wird man sehen, dass er sehr, sehr klein ist - nur etwa 24 Kilobyte, was etwa 20.000 Mal kleiner ist als der Container, den wir mit dem Alpine-Basisimage erstellt haben:
REPOSITORY      TAG       IMAGE ID       CREATED              SIZE
hello_scratch   latest    8459e05660ad   About a minute ago   24.1kB

Obwohl die Erzeugung eines komplett neuen Base-Images für eine Python-Anwendung also mehr Aufwand erfordert, bekommt man einen extrem effizienten und (aufgrund seiner minimalistischen Natur) sicheren Container.