Ankündigung der ersten Veröffentlichung des Moduls python-multiprocessing-intenum, das einen multiprocessing-sicheren Enum-Wert für die Python-Programmiersprache bereitstellt.
Hintergrund
Innerhalb eines großen Kundenprojekts mit mehreren Komponenten, die in einer Kubernetes-Umgebung laufen, standen wir vor einem spezifischen Problem, für das es keine generische Lösung gab, als wir eine Daemon-Worker-Komponente implementiert haben.
Da immer nur eine Berechnung gleichzeitig laufen kann, verfügt die Komponente über einen internen Zustand, um zu verfolgen, ob sie sich im Leerlauf befindet und bereit ist, neue Berechnungen anzunehmen, mit einer Berechnung beschäftigt ist oder darauf wartet, sich herunterzufahren, z. B. während eines Rolling Upgrade.
Neben dem eigentlichen Berechnungsprozess, der erzeugt wird, stellt die Komponente eine auf Django Rest Framework basierende API mittels mehrerer Webserver-Prozesse oder -Threads bereit, die es einer anderen Scheduler-Komponente ermöglicht, den aktuellen Zustand des Workers abzufragen und neue Berechnungsaufträge einzureichen. Daher muss auf den internen Zustand sicher von mehreren Prozessen und/oder Threads aus zugegriffen und dieser geändert werden können.
Der natürliche Datentyp, um einen Zustand in den meisten High-Level-Programmiersprachen einschließlich Python abzubilden, ist ein Enum-Typ, dem man symbolische Namen zuordnen kann, die den Zuständen entsprechen. Wenn es darum geht, Daten über mehrere Prozesse oder Threads hinweg in Python auszutauschen, kommt das Modul „ multiprocessing “ ins Spiel, das gemeinsam genutzte „ ctypes “ Objekte bereitstellt, d. h. grundlegende Datentypen wie Zeichen und Integer, sowie Locking-Primitive, um den Austausch größerer, komplexer Datenstrukturen zu handhaben.
Es bietet jedoch keine fertig nutzbare Möglichkeit, einen Enum-Wert auszutauschen.
Da das Problem ziemlich verbreitet ist, erforderte es eine generische und wiederverwendbare Lösung, und so wurde die Idee für „ IntEnumValue “ geboren, eine Implementierung eines multiprocessing-sicheren, gemeinsam genutzten Objekts für „ IntEnum “ Enum-Werte.
Funktionen
Für den Benutzer erscheint es und kann es wie ein Python „ IntEnum“ verwendet werden, das ein Enum ist, dessen benannte Werte jeweils einem eindeutigen Integer entsprechen.
Intern verwendet es dann ein „ multiprocessing.Value “ gemeinsam genutztes „ ctypes “ Integer-Objekt, um diesen Integer-Wert des Enum auf multiprocessing-sichere Weise zu speichern.
Um einen Programmierstil zu unterstützen, der proaktiv vor Programmierfehlern schützt, ist das Modul vollständig typisiert, was eine statische Prüfung mit „ mypy “ ermöglicht und als generische Klasse implementiert ist, die den Typ des zugrunde liegenden spezifischen Enum als Parameter entgegennimmt.
Das Modul python-multiprocessing-intenum wird mit Unit-Tests geliefert, die eine vollständige Abdeckung seines Codes gewährleisten.
Anwendungsbeispiele
Um die Verwendung zu veranschaulichen und die Funktionen besser zu demonstrieren, wird als Beispiel ein Multithread-Worker verwendet, der einen internen Zustand hat, der als Enum dargestellt wird und über seine Threads hinweg verfolgt werden soll.
Um ein multiprocessing-sicheres „ IntEnum“ zu verwenden, definieren Sie zuerst den eigentlichen zugrunde liegenden „ IntEnum “ Typ:
class WorkerStatusEnum(IntEnum): UNAVAILABLE = enum.auto() IDLE = enum.auto() CALCULATING = enum.auto()
IntEnumValue ist eine generische Klasse, die einen Typparameter für den Typ des zugrunde liegenden „ IntEnum“ akzeptiert.
Definieren Sie also den entsprechenden „ IntEnumValue “ Typ für diesen „ IntEnum wie folgt:
class WorkerStatus(IntEnumValue[WorkerStatusEnum]): pass
Was dies bewirkt, ist die Definition eines neuen WorkerStatus Typs, der ein „ IntEnumValue “ ist, der parametrisiert ist, um den spezifischen „ IntEnum “ Typ WorkerStatusEnum zu handhaben.
Er kann wie ein Enum verwendet werden, um den Status zu speichern:
>>> status = WorkerStatus(WorkerStatusEnum.IDLE) >>> status.name 'IDLE' >>> status.value 2 >>> with status.get_lock(): ... status.set(WorkerStatusEnum.CALCULATING) >>> status.name 'CALCULATING' >>> status.value 3
Der Versuch, ihn auf einen anderen Typ von „ IntEnum “ zu setzen, wird jedoch als TypeError abgefangen:
>>> class FrutEnum(IntEnum):
... APPLE = enum.auto()
... ORANGE = enum.auto()
>>> status.set(FrutEnum.APPLE)
Traceback (most recent call last):
File "", line 1, in
File "/home/elho/python-multiprocessing-intenum/src/multiprocessing_intenum/__init__.py", line 60, in set
raise TypeError(message)
TypeError: Can not set '<enum 'WorkerStatusEnum'>' to value of type '<enum 'FrutEnum'>'
Dies hilft, Programmierfehler zu vermeiden, bei denen fälschlicherweise ein anderes Enum verwendet wird.
Neben der direkten Verwendung kann der erstellte WorkerStatus Typ natürlich auch in eine dedizierte Klasse verpackt werden, worauf die verbleibenden Beispiele näher eingehen werden:
class WorkerState:
def __init__(self) -> None:
self.status = WorkerStatus(WorkerStatusEnum.IDLE)
Bei Verwendung mehrerer „ multiprocessing.Value “ Instanzen (einschließlich „ IntEnumValue “), die sich ein Lock teilen sollen, um sicherzustellen, dass sie nur in einem konsistenten Zustand geändert werden können, übergeben Sie dieses gemeinsam genutzte Lock als Keyword-Argument bei der Instanziierung:
class WorkerState:
def __init__(self) -> None:
self.lock = multiprocessing.RLock()
self.status = WorkerStatus(WorkerStatusEnum.IDLE, lock=self.lock)
self.job_id = multiprocessing.Value("i", -1, lock=self.lock)
def get_lock(self) -> Lock | RLock:
return self.lock
Um zu vermeiden, dass die set() Methode aufgerufen werden muss, um dem Attribut „ IntEnumValue “ einen Wert zuzuweisen, wird empfohlen, das eigentliche Attribut für die Klasse privat zu halten und Getter- und Setter-Methoden für eine öffentliche Eigenschaft zu implementieren, die dieses Implementierungsdetail verbirgt, z. B. wie folgt:
class WorkerState:
def __init__(self) -> None:
self._status = WorkerStatus(WorkerStatusEnum.IDLE)
@property
def status(self) -> WorkerStatusEnum:
return self._status # type: ignore[return-value]
@status.setter
def status(self, status: WorkerStatusEnum | str) -> None:
self._status.set(status)
Das Ergebnis kann eleganter verwendet werden, indem man Werte einfach dem Status-Attribut zuweist:
>>> state = WorkerState() >>> state.status.name 'IDLE' >>> with state.get_lock(): ... state.status = WorkerStatusEnum.CALCULATING >>> state.status.name 'CALCULATING'
Der spezifische „ IntEnumValue “ Typ kann Methoden überschreiben, um weitere Funktionalitäten hinzuzufügen.
Ein häufiges Beispiel ist das Überschreiben der set() Methode, um Logging hinzuzufügen:
class WorkerStatus(IntEnumValue[WorkerStatusEnum]):
def set(self, value: WorkerStatusEnum | str) -> None:
super().set(value)
logger.info(f"WorkerStatus set to '{self.name}'")
Die gemeinsame Nutzung all dieser Funktionen und Anwendungsfälle ermöglicht die elegante, kohäsive und robuste Handhabung des internen Zustands von Multiprocessing-Workern.
Nachdem die vorausgegangenen Artikel eine Einführung in AppArmor gegeben sowie das Vorgehen zum Erstellen eines AppArmor-Profils für nginx beschrieben haben, geht dieser Artikel noch einen Schritt weiter: neben statischen Inhalten soll der nginx-Webserver auch klassische CGI-Scripte ausführen und deren Ausgabe zurückliefern können.
Obwohl das Common Gateway Interface, kurz CGI, seine Hochzeit in den 1990er Jahren erlebte und vielerorts durch modernere Alternativen verdrängt wurde, finden sich auch heute noch Systeme und Prozesse bei denen jahrzehntelang gepflegte CGI-Scripte zum Einsatz kommen.
Unabhängig davon eignet sich die Thematik für diesen Artikel deshalb besonders, weil der Aufbau und die Einrichtung eines Szenarios einfach genug zu beschreiben ist, sodass das eigentliche Thema für diesen Artikel, nämlich AppArmor-Kindprofile, problemlos auf andere Konstrukte übertragen werden kann.
nginx und fcgiwrap
Eine weit verbreitete Methode, um den nginx-Webserver um die Fähigkeit CGI-Scripte ausführen zu können zu erweitern, ist die Verwendung von fcgiwrap.
Die Projektbeschreibung nennt fcgiwrap einen “einfachen FastCGI-Wrapper für CGI-Scripte”, also ein Programm, das herkömmliche CGI-Scripte ausführt und für diese die FastCGI-Kommunikation mit dem Webserver übernimmt, ohne dass die eigentlichen Scripte angepasst werden müssen.
Um den Rahmen dieses Artikels nicht zu sprengen sei auf die Anleitung aus dem nginx-Wiki zur Installation und Konfiguration von fcgiwrap verwiesen.
Beispielkonfiguration
Das folgende Listing zeigt die für unser Szenario verwendete Konfigurationsdatei /etc/nginx/conf.d/cgi-bin.conf, welche auf der von fcgiwrap mitgelieferten Beispielkonfiguration, zu finden unter /usr/share/doc/fcgiwrap/examples/nginx.conf, basiert.
location /cgi-bin/ {
gzip off;
root /var/www;
fastcgi_pass unix:/var/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}Aus dem Listing ist leicht zu entnehmen, dass sich im Ordner /cgi-bin/ unterhalb des root-Verzeichnisses /var/www Scripte befinden, die ausgeführt werden sollen – in diesem Szenario handelt es sich dabei um einfache Python-Scripte.
Das Programm fcgiwrap läuft als eigenständiger Prozess auf dem System und öffnet ein Unix-Socket, über das der nginx-Webserver gemäß dem FastCGI-Protokoll mit fcgiwrap kommunizieren kann, um die Ausführung der Scripte zu veranlassen und die dabei erzeugte Ausgabe zurück zu erhalten.
Für die Überwachung und Absicherung des nginx-Webservers mittels AppArmor wird das im vorherigen Artikel erstellte Profil benutzt. Obwohl bislang ungenutzte Funktionen des nginx zum Einsatz kommen muss dieses nicht angepasst werden: auf den Ordner cgi-bin/ greift der Webserver selbst überhaupt nicht zu; stattdessen veranlasst er fcgiwrap über das Socket das angeforderte Script auszuführen.
Auch der Zugriff auf das Socket /var/run/fcgiwrap.socket muss nicht explizit erlaubt werden: hierfür existieren bereits Regeln in abstractions/base.
Kindprozesse
Spätestens hier wird klar: wenn nicht nginx, sondern fcgiwrap die Scripte ausführt, greift das verwendete AppArmor-Profil nicht, da dieses nur die nginx-Prozesse überwacht. Es muss also ein weiteres Profil angelegt werden!
Mit einem einzigen Profil für fcgiwrap ist es dabei jedoch nicht getan, denn ein Script, das von fcgiwrap ausgeführt wird, läuft als eigenständiger Kindprozess: für ein Python-Script startet fcgiwrap also beispielsweise den Python-Interpreter – und der sollte dann auch von AppArmor überwacht werden!
Anders gesagt: da ein Profil für fcgiwrap wiederum nur Prozesse der Programmdatei /usr/sbin/fcgiwrap berücksichtigt, liefe der Python-Interpreter ohne AppArmor-Kontrolle. Durch spezielle Execute-Permissions und Kindprofile lässt sich jedoch genau bestimmen ob und in welchem Rahmen Kindprozesse eines von AppArmor überwachten Prozesses ausgeführt werden dürfen.
Kindprofile
Das folgende Listing zeigt den Inhalt des AppArmor-Profils /etc/apparmor.d/usr.sbin.fcgiwrap. Anhand dieses Listings soll die Definition von Kindprofilen erläutert werden:
include <tunables/global>
@{CGIBIN}=/var/www/cgi-bin
profile fcgiwrap /usr/sbin/fcgiwrap {
include <abstractions/base>
/usr/sbin/fcgiwrap mr,
@{CGIBIN}/* rcix,
profile @{CGIBIN}/* {
include <abstractions/base>
include <abstractions/nameservice>
include <abstractions/python>
/usr/bin/python3.9 mr,
@{CGIBIN}/* r,
}
}Auf den ersten Blick gleicht das gezeigte Profil den Profilen aus den vergangenen Artikeln. Es fällt jedoch auf, dass innerhalb des Profilteils ein weiteres, namenloses Profil definiert wird, dessen Pfadangabe auf alle Dateien in /var/www/cgi-bin/ passt.
Dieses sogenannte Kindprofil wird durch die Regel @{CGIBIN}/* rcix im äußeren Profil auf alle durch fcgiwrap ausgeführten Scripte im Ordner CGIBIN angewendet. Die Permission r steht dabei wie gehabt für Lesezugriff; interessanter ist die Permission cix: x erlaubt wie gehabt die Ausführung der Datei (execute), c gibt jedoch an, dass dabei ein entsprechendes Kindprofil angewendet werden soll. Wird das Kindprofil nicht gefunden sorgt i dafür, dass das aktuelle Profil vererbt (inherited) wird.
Würde die Permission c als Großbuchstabe, also C, angegeben, würden vor der Ausführung die Umgebungsvariablen gelöscht. Da CGI zur einwandfreien Funktion jedoch auf deren Erhalt angewiesen ist, kam diese Option hier nicht in Frage. Als Alternative zu i gibt es die Permission u, die dafür sorgt, dass der Kindprozess unconfined, also uneingeschränkt, läuft, falls das Kindprofil nicht gefunden wird. Auch dies wäre in diesem Falle nicht wünschenswert.
Im Quick guide to AppArmor profile Language findet sich im Abschnitt File permissions eine Übersicht an Möglichkeiten die Execute-Permission genauer zu spezifizieren. In der AppArmor Core Policy Reference werden sie im Abschnitt Execute rules noch einmal genauer gruppiert erläutert.
Da es sich bei Python um eine interpretierte Sprache handelt, werden die Scripte, wie oben erwähnt, nicht selbst ausgeführt, sondern (vereinfacht gesagt) von einem Interpreter geladen und von diesem ausgeführt. Der Aufbau des eigentlichen Kindprofils sollte daher leicht verständlich sein: der Python-Interpreter /usr/bin/python3.9 darf in den Speicher geladen und ausgeführt werden (mr) und alle Dateien im Ordner @{CGIBIN} lesen, eine Berechtigung zur Ausführung ist nicht nötig.
Zur Ausführung eines Python-Scripts werden in der Regel noch verschiedene Bibliotheken und Module benötigt. Diese, beziehungsweise ihre Pfade, wurden bereits in <abstractions/python> zusammengefasst, sodass AppArmor-Profile für Python-Scripte diese Regeln lediglich mittels include einbinden müssen.
Das obige Kindprofil enthält sonst keinerlei Regeln. Daher beschränken sich die erlaubten Zugriffe auf solche, die von den in den Abstractions enthaltenen Regeln definiert werden. Durch die Erfahrungen aus den letzten beiden Artikeln ist das Kindprofil jedoch schnell um entsprechende Regeln erweitert. Auch aa-logprof kann hierbei wieder zu Rate gezogen werden.
Sollen die Scripte beispielsweise Dateien im Verzeichnis /data lesend und schreibend verarbeiten dürfen, geht es von Hand immer noch am schnellsten: es muss lediglich die Zeile /data/* rw, in das Kindprofil eingetragen werden.
Der Anfang ist gemacht
Der Einsatz von Kindprofilen beschränkt sich nicht nur auf Webserver: jegliche Software, die andere Programme als Kindprozess startet lässt sich nach diesem Prinzip genauso gut mit AppArmor überwachen und absichern. Gleiches gilt für Scripte und Interpreter anderer Programmiersprachen.
Wir unterstützen Sie gerne
Ob AppArmor, Debian oder PostgreSQL: mit über 22+ Jahren an Entwicklungs- und Dienstleistungserfahrung im Open Source Bereich kann die credativ GmbH Sie mit einem beispiellosen und individuell konfigurierbaren Support professionell begleiten und Sie in allen Fragen bei Ihrer Open Source Infrastruktur voll und ganz unterstützen.
Sie haben Fragen zu unserem Artikel oder würden sich wünschen, dass die Spezialisten von credativ sich eine andere Software ihrer Wahl angucken? Dann schauen Sie doch vorbei und melden sich über unser Kontaktformular oder schreiben uns eine E-Mail an info@credativ.de.
Über credativ
Die credativ GmbH ist ein herstellerunabhängiges Beratungs- und Dienstleistungsunternehmen mit Standort in Mönchengladbach. Seit dem erfolgreichen Merger mit Instaclustr 2021 ist die credativ GmbH das europäische Hauptquartier der Instaclustr Gruppe.
Die Instaclustr Gruppe hilft Unternehmen bei der Realisierung eigener Applikationen im großen Umfang dank Managed-Plattform-Solutions für Open Source Technologien wie zum Beispiel Apache Cassandra®, Apache Kafka®, Apache Spark™, Redis™, OpenSearch™, Apache ZooKeeper™, PostgreSQL® und Cadence. Instaclustr kombiniert eine komplette Dateninfrastruktur-Umgebung mit praktischer Expertise, Support und Consulting um eine kontinuierliche Leistung und Optimierung zu gewährleisten. Durch Beseitigung der Komplexität der Infrastruktur wird es Unternehmen ermöglicht, ihre internen Entwicklungs- und Betriebsressourcen auf die Entwicklung innovativer kundenorientierter Anwendungen zu geringeren Kosten zu konzentrieren. Zu den Kunden von Instaclustr gehören einige der größten und innovativsten Fortune-500-Unternehmen.