Die Magie der Schichten: Effizienz und Konsistenz von Container-Images verstehen

Die Magie der Schichten: Effizienz und Konsistenz von Container-Images verstehen

Die Dateisystemansicht in containerisierten Prozessen

Wie sieht das Dateisystem für Prozesse aus, die in einem Container laufen? Man könnte sofort an den Mount-Namespace denken – die Prozesse in einem Container sollten ein völlig unabhängiges Dateisystem sehen. Auf diese Weise könnten Operationen in den Verzeichnissen des Containers, wie z.B. /tmp, durchgeführt werden, ohne dass der Host-Rechner oder andere Container Einfluss nehmen. Ist das jedoch der Fall?

Die Rolle des Mount-Namespace

Der Mount-Namespace funktioniert etwas anders als andere Namespaces, da seine Wirkung auf die Ansicht des Dateisystems durch den Container-Prozess erst mit einem Mount-Vorgang einsetzt. Als normale Benutzer wünschen wir uns jedoch eine benutzerfreundlichere Situation: Jedes Mal, wenn ein neuer Container erstellt wird, soll der Container-Prozess ein Dateisystem sehen, das eine isolierte Umgebung darstellt und nicht vom Host geerbt wird. Wie kann das erreicht werden? Es ist nicht schwer vorstellbar, dass wir das gesamte Root-Verzeichnis „/“ vor dem Start des Container-Prozesses neu einhängen könnten. Dank des Mount-Namespace wäre dieser Mount für den Host unsichtbar, sodass der Container-Prozess darin frei arbeiten kann. Im Linux-Betriebssystem gibt es einen Befehl namens chroot, der diese Aufgabe in einer Shell bequem erledigen kann. Wie der Name schon sagt, hilft er dabei, das Root-Dateisystem zu ändern, indem er das Root-Verzeichnis des Prozesses auf einen bestimmten Ort umleitet.

Container-Images und rootfs

Tatsächlich wurde der Mount-Namespace auf Basis kontinuierlicher Verbesserungen von chroot erfunden und ist der erste Namespace in Linux. Damit dieses Root-Verzeichnis für Container „realer“ wirkt, mounten wir normalerweise ein vollständiges Betriebssystem-Dateisystem unter diesem Root, z.B. ein Ubuntu 16.04 ISO. Nach dem Start des Containers zeigt die Ausführung von ls / im Container dann alle Verzeichnisse und Dateien von Ubuntu 16.04. Dieses Dateisystem, das auf das Root-Verzeichnis des Containers gemountet wird und dem Container-Prozess eine isolierte Ausführungsumgebung bietet, wird als „Container-Image“ bezeichnet. Es hat auch einen technischeren Begriff: rootfs (Root-Dateisystem). Das nach dem Betreten des Containers ausgeführte /bin/bash ist die ausführbare Datei im Verzeichnis /bin, die sich völlig von dem /bin/bash des Hosts unterscheidet. Nun sollten Sie verstehen, dass das Kernprinzip hinter Docker-Projekten im Wesentlichen darin besteht, den zu erstellenden Benutzerprozess mit Linux-Namespaces zu konfigurieren, bestimmte Cgroups-Parameter zu setzen und das Root-Verzeichnis des Prozesses zu ändern (Change Root). So entsteht ein vollständiger Container.

Gemeinsamer Kernel und Anwendungsabhängigkeiten in Containern

Docker verwendet jedoch für den letzten Schritt des Wechsels lieber den Systemaufruf pivot_root und fällt auf chroot zurück, wenn das System pivot_root nicht unterstützt. Obwohl diese beiden Systemaufrufe ähnliche Funktionen haben, gibt es feine Unterschiede. Außerdem ist es wichtig zu klären, dass rootfs nur die Dateien, Konfigurationen und Verzeichnisse eines Betriebssystems enthält, aber nicht den Kernel. In Linux werden diese beiden Teile getrennt gespeichert, und das Kernel-Image einer bestimmten Version wird nur beim Booten geladen. Daher enthält rootfs nur die „Hülle“ des Betriebssystems, nicht seine „Seele“. Wo also ist die „Seele“ des Betriebssystems für Container? In Wirklichkeit teilen sich alle Container auf derselben Maschine den Kernel des Host-Betriebssystems. Das bedeutet, dass Ihre Anwendung, wenn sie Kernel-Parameter konfigurieren, zusätzliche Kernel-Module laden oder direkt mit dem Kernel interagieren muss, diese Operationen und Abhängigkeiten auf den Kernel des Host-Betriebssystems abzielen, der eine „globale Variable“ für alle Container auf dieser Maschine ist. Dies ist einer der Hauptnachteile von Containern im Vergleich zu virtuellen Maschinen: Letztere haben nicht nur simulierte Hardware als Sandbox, sondern führen auch ein vollständiges Gast-Betriebssystem in jeder Sandbox aus, das von Anwendungen genutzt werden kann. Trotz allem bietet rootfs eine wichtige Eigenschaft, die viel beworben wird: Konsistenz.

Das Konzept der Schichten in Container-Images

Was ist die „Konsistenz“ von Containern? Aufgrund der Unterschiede zwischen Cloud- und lokalen Serverumgebungen war der Verpackungsprozess von Anwendungen schon immer einer der „schmerzhaftesten“ Schritte bei der Verwendung von PaaS. Mit Containern, oder genauer gesagt mit Container-Images (d.h. rootfs), wurde dieses Problem jedoch elegant gelöst. Da rootfs nicht nur die Anwendung, sondern die gesamten Dateien und Verzeichnisse des Betriebssystems verpackt, kapselt es alle Abhängigkeiten ein, die für die Ausführung der Anwendung erforderlich sind. Tatsächlich beschränkte sich das Verständnis der meisten Entwickler von Anwendungsabhängigkeiten auf die Programmiersprachenebene, wie z.B. Golangs Godeps.json. Eine lange übersehene Tatsache ist jedoch, dass das Betriebssystem selbst die vollständigste „Abhängigkeitsbibliothek“ ist, die eine Anwendung zum Laufen benötigt. Mit der Fähigkeit von Container-Images, das Betriebssystem zu „verpacken“, wird diese grundlegende Abhängigkeitsumgebung endlich Teil der Anwendungs-Sandbox. Dies verleiht Containern ihre viel gepriesene Konsistenz: Egal ob auf einem lokalen Rechner, in der Cloud oder woanders – Benutzer müssen nur das Container-Image entpacken, um die vollständige Ausführungsumgebung nachzubilden, die für die Anwendung erforderlich ist.

Inkrementelles Schichtdesign in Container-Images

Diese Konsistenz auf Betriebssystemebene überbrückt die Kluft zwischen lokaler Entwicklung und entfernten Ausführungsumgebungen für Anwendungen. Allerdings ist Ihnen vielleicht ein weiteres kniffliges Problem aufgefallen: Müssen wir jedes Mal ein neues rootfs erstellen, wenn wir eine neue Anwendung entwickeln oder eine bestehende aktualisieren? Eine intuitive Lösung könnte darin bestehen, nach jeder „sinnvollen“ Operation bei der Erstellung ein rootfs zu speichern, sodass Kollegen das benötigte rootfs verwenden können. Diese Lösung ist jedoch nicht skalierbar. Der Grund ist, dass, sobald Kollegen dieses rootfs modifizieren, keine Beziehung zwischen dem alten und dem neuen rootfs mehr besteht, was zu extremer Fragmentierung führt. Da diese Modifikationen auf einem alten rootfs basieren, können diese Änderungen inkrementell vorgenommen werden? Der Vorteil dieses Ansatzes ist, dass jeder nur den inkrementellen Inhalt relativ zum Basis-rootfs verwalten muss, anstatt bei jeder Modifikation einen „Fork“ zu erstellen. Die Antwort ist natürlich ja. Genau aus diesem Grund folgte Docker bei der Implementierung von Docker-Images nicht dem Standardprozess der rootfs-Erstellung, sondern machte eine kleine Innovation: Docker führte das Konzept der Schichten (Layers) in sein Image-Design ein. Jede Operation, die Benutzer zur Erstellung eines Images durchführen, erzeugt eine Schicht, die ein inkrementelles rootfs darstellt. Diese Idee entstand nicht aus dem Nichts, sondern nutzte eine Fähigkeit namens Union File System (UnionFS), deren Hauptfunktion darin besteht, mehrere Verzeichnisse von verschiedenen Orten in einem einzigen Verzeichnis zusammenzuführen (Union Mount).

Schichtung in Containern

Schichtung in Containern

Teil 1: Nur-Lese-Schichten. Dies sind die unteren fünf Schichten des rootfs dieses Containers, die den fünf Schichten des ubuntu:latest-Images entsprechen. Sie sind als schreibgeschützt (ro+wh, d.h. readonly+whiteout) gemountet. Jede Schicht enthält inkrementell einen Teil des Ubuntu-Betriebssystems.

Teil 2: Lese-Schreib-Schicht. Dies ist die oberste Schicht des rootfs dieses Containers (6e3be5d2ecccae7cc), gemountet als rw, d.h. lesen-schreiben. Bevor Dateien geschrieben werden, ist dieses Verzeichnis leer. Sobald ein Schreibvorgang im Container durchgeführt wird, erscheinen die Änderungen inkrementell in dieser Schicht. Aber haben Sie bedacht, was passiert, wenn Sie eine Datei aus der Nur-Lese-Schicht löschen möchten? Um dieses Löschen zu erreichen, erstellt AuFS eine Whiteout-Datei in der Lese-Schreib-Schicht, um die Datei in der Nur-Lese-Schicht zu „verdecken“. Wenn Sie beispielsweise eine Datei namens foo aus der Nur-Lese-Schicht löschen, wird tatsächlich eine Datei namens .wh.foo in der Lese-Schreib-Schicht erstellt. Wenn diese Schichten dann zusammengeführt werden, wird die Datei foo durch die Datei .wh.foo verdeckt und „verschwindet“. Diese Funktionalität ist das, was die Mount-Methode „ro+wh“ bedeutet, d.h. schreibgeschützt plus Whiteout.

Teil 3: Init-Schicht. Dies ist eine interne Schicht, die vom Docker-Projekt generiert wird und mit „-init“ endet. Sie befindet sich zwischen den Nur-Lese- und Lese-Schreib-Schichten. Die Init-Schicht wird speziell verwendet, um Informationen wie /etc/hosts und /etc/resolv.conf zu speichern. Die Notwendigkeit einer solchen Schicht ergibt sich daraus, dass diese Dateien ursprünglich zum schreibgeschützten Ubuntu-Image gehören, aber oft spezifische Werte wie den Hostnamen benötigen, die beim Container-Start geschrieben werden müssen. Daher sind Änderungen in der Lese-Schreib-Schicht erforderlich. Diese Änderungen gelten jedoch in der Regel nur für den aktuellen Container und sollen nicht mit der Lese-Schreib-Schicht festgeschrieben werden, wenn docker commit ausgeführt wird. Daher besteht Dockets Ansatz darin, diese modifizierten Dateien in einer separaten Schicht zu mounten. Wenn Benutzer docker commit ausführen, wird nur die Lese-Schreib-Schicht festgeschrieben, ohne diesen Inhalt.

Vorteile des Schichtdesigns in Container-Images

Durch das „geschichtete Image“-Design mit Docker-Images als Kern sind Techniker aus verschiedenen Unternehmen und Teams eng verbunden. Da Operationen an Container-Images inkrementell sind, ist der bei jedem Pull oder Push übertragene Inhalt viel kleiner als mehrere vollständige Betriebssysteme; gemeinsame Schichten bedeuten, dass der gesamte Speicherplatz, der für all diese Container-Images benötigt wird, geringer ist als die Summe jedes einzelnen Images. Diese Agilität in der Zusammenarbeit basierend auf Container-Images übertrifft die der virtuellen Maschinen-Disk-Images bei weitem, die mehrere GB groß sein können. Noch wichtiger ist, dass, sobald ein Image veröffentlicht wurde, das Herunterladen an jedem Ort der Welt genau denselben Inhalt ergibt und die ursprüngliche Umgebung des Image-Erstellers vollständig reproduziert.

Die Auswirkungen von Container-Images auf Softwareentwicklungs-Workflows

Die Erfindung von Container-Images überbrückt nicht nur jeden Schritt des „Entwickeln – Testen – Bereitstellen“-Prozesses, sondern bedeutet auch, dass Container-Images in Zukunft zur vorherrschenden Methode der Softwareverteilung werden. Diese Verteilungsmethode bietet Vorteile wie Leichtgewichtigkeit, hohe Konsistenz und fördert die Zusammenarbeit, wodurch Softwareentwicklung und -bereitstellung effizienter und zuverlässiger werden. Mit ihrer Leichtigkeit, Konsistenz und Effizienz wird die Container-Technologie zunehmend zu einem unverzichtbaren Werkzeug in der Softwareentwicklung und im Betrieb. Mit der kontinuierlichen Weiterentwicklung und Innovation der Technologie gibt es Grund zu der Annahme, dass die Container-Technologie in Zukunft eine noch wichtigere Rolle spielen wird.

Novita AI, die One-Stop-Plattform für grenzenlose Kreativität, die Ihnen Zugang zu über 100 APIs bietet. Von Bildgenerierung und Sprachverarbeitung bis hin zur Audioverbesserung und Videobearbeitung, kostengünstig nach Verbrauch, befreit sie Sie von GPU-Wartungsproblemen, während Sie Ihre eigenen Produkte entwickeln. Kostenlos testen.