Nachhaltige Softwarebereitstellung in Kubernetes

Seite 2: Migration und Herausforderungen

Inhaltsverzeichnis

Die Serverless-Migration erscheint auf den ersten Blick simpel, gilt es doch lediglich, das existierende Deployment in einen Knative-Service zu überführen, den der Knative Serving Operator dann zur Laufzeit in ein Kubernetes-Deployment umwandelt. Hinzu kommen schließlich noch verschiedene Kubernetes-Dienste sowie eine – automatisch erstellte – Ingress-Ressource. Der bereitgestellte Service skaliert nun basierend auf den tatsächlichen Anfragen und schaltet sich vollständig ab, falls Anfragen ausbleiben – in der Standardeinstellung bereits nach 30 Sekunden. Damit wäre die Migration abgeschlossen, sofern man die oben genannten Qualitätskriterien unberücksichtigt ließe. Will man sie hingegen erfüllen, ergeben sich noch einige Herausforderungen.

  1. Abruf der Applikationsmetriken durch Prometheus: In der Standardkonfiguration sammelt der Knative-Service keine Metriken von Prometheus. Soll er die Daten abrufen, ist dafür ein Start der Applikation erforderlich, da Prometheus die Metriken stets über einen Pull-basierten Ansatz abruft. Prometheus ist standardmäßig darauf ausgelegt, in regelmäßigen Abständen die Metriken der Ziel-Applikationen über einen eigens dafür vorgesehenen Endpunkt abzurufen, zu speichern und für weitere Auswertungen bereitzustellen. Für dauerhaft aktive Anwendungen ist das sinnvoll, aber es würde die angestrebte Nachhaltigkeit bei Serverless-Applikationen konterkarieren.
  2. Verfügbarkeit des Service unter einem dedizierten Endpunkt: Der Endpunkt, unter dem der Knative-Service bereitstehen soll, lässt sich in der Regel nicht frei festlegen. In der Standardkonfiguration wird der Service immer nach dem folgenden Schema außerhalb des Clusters verfügbar gemacht:<Service Name>.<ServiceNamespace>.<Cluster Url> Dazu generiert Knative aus der Knative-Service-Definition automatisch Ingress- und Kubernetes-Service-Ressourcen. Daher lässt sich das Schema zwar anpassen, der tatsächliche Endpunkt eines einzelnen Service aber nicht frei bestimmen.
  3. Dynamisches Hochskalieren der Anwendung: Auch das dynamische Hochskalieren einer Serverless-Anwendung aus dem ausgeschalteten Zustand heraus kann Probleme verursachen. Muss eine Anwendung erst noch starten, bevor sie auf eine Anfrage reagieren kann, dauert das Beantworten mindestens so lange wie der Applikationsstart. Dieser Kaltstart dauert je nach verwendeter Programmiersprache und Framework von wenigen Millisekunden bis zu mehreren zehn Sekunden. Die Beispielanwendung ist mit Spring Boot und Java 20 entwickelt. Sie benötigt ohne Optimierungen zwischen einer und zehn Sekunden, bis sie Anfragen beantworten kann – je nachdem, wie viel CPU-Leistung ihr zur Verfügung steht. Für die meisten Anwendungsfälle ist eine Antwortzeit von zehn Sekunden bereits inakzeptabel, etwa wenn Nutzerinnen oder Nutzer eine Anwendung über einen Webbrowser aufrufen.

Für die im Zusammenhang mit den definierten Qualitätskriterien auftretenden Probleme gibt es Lösungsansätze. Das Abrufen der Anwendungsmetriken lässt sich sogar auf zwei unterschiedlichen Wegen umsetzen.

Bei einem Pull-basierten Ansatz muss sichergestellt sein, dass ein Abrufen der Metriken nicht zu unnötigen Applikationsstarts führt, wenn die Applikation gerade ruht. Andererseits darf das Abrufen der Metriken nicht dauerhaft verhindern, dass Knative die Anwendung herunterskaliert. Beides lässt sich durch eine geeignete Porttrennung erreichen. Beim Start eines Pods öffnet Knative mehrere Ports in einem separaten Container, dem Queue-Proxy. Knative nutzt diese Ports, um Anfragen an die eigentliche Applikation zu leiten. Hierbei bleibt der durch den Applikationscode geöffnete Port bestehen. Ist eine Anfrage direkt an den Applikations-Port gerichtet, ignoriert Knative diese für die Skalierung.

Beim Monitoring von Knative-Applikationen können Metriken verloren gehen, die zwischen dem letzten Abruf durch Prometheus und dem Skalieren der Applikation auf null geschrieben wurden. Abhängig vom Anwendungsfall kann dieser Verlust verkraftbar sein, da sich das Abrufintervall in Prometheus konfigurieren lässt. Wählt man etwa ein Abrufintervall von 15 Sekunden, verliert man also maximal die Metriken in diesem Zeitfenster, sollte Knative die Anwendung kurz vor Ablauf der 15 Sekunden herunterskalieren. Muss jedoch sichergestellt sein, dass sämtliche Metriken an Prometheus gesendet werden, stößt der Pull-basierte Ansatz an seine Grenzen. Soll Prometheus beispielsweise auch das Auftreten schwerer Fehler überwachen, und ein Fehlerevent tritt zwischen dem letzten Abruf der Metriken und dem Herunterfahren der Applikation auf, bleibt es im Monitoring unsichtbar. Gegebenenfalls notwendige Maßnahmen zur Fehlerbehebung lassen sich nicht einleiten.

Alternativ lassen sich moderne Monitoring-Lösungen – darunter auch Prometheus – zum Abrufen von Metriken auch in einem Push-basierten Modus betreiben. Im Falle von Prometheus bietet sich dazu das Push-Gateway an. Es stellt einen Endpunkt zum Abliefern der Metriken bereit. In der Zielumgebung läuft mit dem Push-Gateway dann zwar dauerhaft eine weitere Komponente, da es aber die Metriken sämtlicher Serverless-Anwendungen entgegennehmen kann, spielt der zusätzliche Ressourcenverbrauch keine nennenswerte Rolle. Der Push-basierte Ansatz lässt sich in der Regel jedoch nicht ohne aufwendige Anpassung des Quellcodes umsetzen – und wird daher hier nicht weiter betrachtet.

apiVersion: serving.knative.dev/v1alpha1
kind: DomainMapping
metadata:
  name: my-route.example.org
spec:
  ref:
    name: Beispiel-App
    kind: Service
    apiVersion: serving.knative.dev/v1

Um einen Knative-Service zusätzlich zum standardmäßig generierten Endpunkt auch über einen weiteren bereitzustellen, lässt sich per DomainMapping ein benutzerdefinierter Endpunkt einrichten. DomainMapping ist eine Kubernetes-Ressource (Custom Resource) und steht in Knative bereits seit der Beta zur Verfügung. Ein Beispiel zeigt das Listing oben. Soll der Knative-Service nur unter einem benutzerdefinierten Endpunkt verfügbar sein, lässt sich das Erstellen des automatisch generierten Endpunktes deaktivieren. Dazu genügt es, dem Knative-Service eine Annotation hinzuzufügen, beispielsweise mit folgendem Konsolenbefehl:

kubectl label kservice ${KSVC_NAME} networking.knative.dev/visibility=cluster-local

Die Kaltstartproblematik lässt sich in der Regel nicht ohne Anpassungen des Quellcodes beheben. Der erforderliche Aufwand hängt stark von der Programmiersprache und dem gewählten Framework ab. Bei modernen Programmiersprachen und Frameworks ist die Wahrscheinlichkeit hoch, dass die Kaltstartzeit der Anwendung sich in einem akzeptablen Rahmen hält. Das Java-Framework Quarkus beispielsweise ist auf schnelle Startzeiten hin optimiert. Die diesem Artikel zugrunde liegende Beispielanwendung ist in Java 20 mit dem weit verbreiteten JVM-Framework Spring Boot implementiert. Diese Kombination führt in der Praxis regelmäßig zu längeren Startzeiten.

Dieses Problem gehen Entwicklerinnen und Entwickler auf verschiedene Art und Weise an. Zum einen lassen sich die CPU-Limits und gegebenenfalls die Requests so anpassen, dass der Anwendung genügend Rechenleistung zur Verfügung steht, um einen möglichst raschen Applikationsstart zu erreichen. Da die Spring-Boot-Anwendung im späteren Regelbetrieb allerdings mit deutlich weniger CPU-Leistung auskommt, führt diese Vorgehensweise in typischen Kubernetes-Deployments zu einer ineffizienten Ressourcenauslastung. Als effizientere Alternative bietet sich daher an, die seit Spring 6.0 weiter optimierten AoT-Processing-Funktionen (Ahead of Time) zu nutzen und die Anwendung über ein natives Graal-VM-Image bereitzustellen. Für die Beispielanwendung lässt sich dieser Weg einfach umsetzen, bei größeren Anwendungen ist jedoch mit höherem Anpassungsaufwand zu rechnen.

Die grundlegende Zielarchitektur der Beispielanwendung. Insbesondere der Anfrage-Fluss von Knative vom Ingress hin zur Anwendung ist hier stark vereinfacht dargestellt. (Abb. 2)

Die aufgeführten Herausforderungen bei der Migration der Beispielanwendung ließen sich in den meisten Fällen einfach und ohne großen Aufwand lösen. Für das Monitoring und das Bereitstellen des Service unter einem bestimmten Endpunkt lassen sich Vorgehensweisen finden, die den gestellten Qualitätsanforderungen genügen. Die Kaltstartproblematik hingegen ist abhängig von der gewählten Programmiersprache und erfordert einen höheren Aufwand.

Ist das Einsparen von Ressourcen das Hauptziel der Migration, ist zu beachten, dass Knative selbst auch CPU- und Arbeitsspeicher-Ressourcen bindet. Laut Dokumentation setzt eine produktive Installation sechs CPU-Kerne und sechs GByte Arbeitsspeicher voraus. Um einen positiven Einfluss auf die Nachhaltigkeit des Gesamtsystems zu erzielen, braucht es also eine kritische Masse an Anwendungen.

Darüber hinaus ist Knative nur für Anwendungen mit volatiler Anfragelast sinnvoll. Dazu bieten sich die im Artikel konkret behandelten Serverless-Anwendungen an, die sich komplett abschalten lassen. Aber auch Anwendungen, die im allgemeinen Betrieb dynamisch und weitgehend unvorhersehbar skalieren müssen, profitieren von der Möglichkeit, Deployments anhand der Rate der HTTP-Requests zu skalieren, statt anhand der CPU-Verbrauchs-Metriken, wie es mit horizontalen Pod-Autoscalern möglich ist. Weist eine Anwendung jedoch ein stabiles, nur seltenen Schwankungen unterworfenes Anfragelastmuster auf, lässt sich der Ressourcenverbrauch durch eine Migration mit Knative nur unwesentlich senken. In solchen Fällen trägt Knative eher dazu bei, die Komplexität des Systems unnötig zu erhöhen.

Neben den bisher genannten Einschränkungen ist zu beachten, dass Knative ausschließlich für zustandslose Anwendungen geeignet ist. Es ist nicht möglich, ein persistentes Volume in einem Knative-Service zu mounten. Außerdem darf die Anwendung ausschließlich über HTTP auf einem bestimmten Port aufgerufen werden. Anwendungen, die Datenverkehr auf mehreren Ports empfangen oder über andere Protokolle kommunizieren, lassen sich nicht mit Knative nutzen.