Wir bringen eine Java-Anwendung in die Cloud - Teil 7

Migration nach AWS: Optimierungen für Serverless-Anwendungen

Christian Müller

©Shutterstock / Romas_Photo

In den vorangehenden sechs Teilen dieser Serie haben wir uns mit der schrittweisen Optimierung einer Anwendung hin zu einer Serverless-Architektur, der Verschlüsselung von Daten und Optimierungen für Container beschäftigt. In diesem abschließenden Teil der Serie werfen wir einen Blick auf mögliche Optimierungen für AWS-Lambda-Funktionen mit dem Ziel, die Sicherheit, Zuverlässigkeit und Performance zu erhöhen sowie die Kosten zu senken.

Im Folgenden möchten wir betrachten, wie wir unsere serverlose Architektur aus dem vierten Artikel ganz konkret hinsichtlich Geschwindigkeit, Durchsatz, Zuverlässigkeit und Kosten optimieren können. Auch in serverlosen Architekturen gibt es in der Regel noch Konfigurationseinstellungen, die wir für unseren konkreten Workload überprüfen und optimieren sollten. In den folgenden Absätzen werden wir jeden Service unserer Architektur betrachten und mögliche Optimierungen diskutieren.

Artikelserie

Optimierung unseres API Gateway

Wie in Teil 4 bereits dargestellt, besitzt das Amazon API Gateway [1] drei unterschiedliche Ausprägungen:

  • Edge-optimized (Standard): Zu bevorzugen, sollten unsere Kunden in Europa oder weltweit verteilt sein. Durch die verwaltete Integration mit Amazon CloudFront [2] werden Daten nah bei unseren Kunden für den schnellen Zugriff zwischengespeichert und der Netzwerkpfad ist mittels des Amazon-Netzwerk-Backbone optimiert.

  • Regional: Zu bevorzugen, wenn unsere Kunden nur aus einem begrenzten Einzugsbereich kommen (z. B. Deutschland) und wir unser Amazon API Gateway nah oder innerhalb dieses Einzugsbereichs bereitstellen können (z. B. Frankfurt). Ein weiterer Grund kann sein, dass wir volle Kontrolle über die Amazon-CloudFront-Distribution benötigen, um sie selbst konfigurieren zu können.

  • Private: Zu bevorzugen, sollte unser API nur intern aus bestimmten Amazon VPCs [3] aufrufbar sein.

Die Wahl der Ausprägung hat einen Einfluss auf die Kosten unseres API Gateways und die Latenz des Aufrufs.

Mit der Nutzung des Amazon API Gateway profitieren wir ohne zusätzliche Kosten vom automatischen Schutz durch AWS Shield Standard [4]. AWS Shield Standard bietet Schutz vor den am häufigsten vorkommenden Infrastrukturangriffen (Ebenen 3 und 4) wie SYN/UDP-Floods, Reflexionsangriffe und andere. Durch deren Abwehr wird eine hohe Verfügbarkeit Ihrer Anwendungen unter AWS sichergestellt. Um einen noch höheren Schutz vor Angriffen zu erhalten, können wir gegen zusätzliche Kosten AWS Shield Advanced in Verbindung mit einer Amazon-CloudFront-Distribution nutzen.

Amazon API Gateway bietet auch eine Integration mit der AWS WAF [5]. Diese Web Application Firewall erlaubt es, uns sehr effektiv vor gängigen Cross-Site-Scripting- oder SQL-Injection-Angriffen aus dem Web zu schützen. Darüber hinaus erlaubt es uns die AWS WAF, bestimmte IP-Adressbereiche oder CIDR-Blöcke auszuschließen (Blacklisting) oder nur bestimmte zuzulassen (Whitelisting).

Explizit soll hier auch nochmal auf die Möglichkeit hingewiesen werden, dass das Amazon API Gateway eine direkte Integration mit anderen AWS Services anbietet. Sollte unsere AWS-Lambda-Funktion keine Geschäftslogik beinhalten und die Anfragen nur an die Amazon DynamoDB weiterleiten, können wir auf diese AWS-Lambda-Funktion verzichten [6] und das Amazon API Gateway den Service aufrufen lassen. Das reduziert unsere Latenz und spart Kosten.

Standardmäßig drosselt das Amazon API Gateway Zugriffe auf 10 000 Requests pro Sekunde [7]. Sollten wir eine solche Last nicht erwarten bzw. sollte unser Backend diese Last nicht handeln können, sollten wir diese Konfiguration auf einen angemessenen Wert reduzieren. Hier haben wir die Möglichkeit, Limits auf unseren Stages (unterschiedliche Versionen unseres API) oder noch feingranularer auf Methodenebene zu definieren. Das ist sinnvoll, da zum Beispiel unsere signup-Methode viel seltener aufgerufen wird als eine Abfrage auf eine Resource. Das erhöht die Ausfallsicherheit unseres API aufgrund einer Überlastung unseres Backends.

Diese Limitierungen machen keine Unterscheidung, von wem der Aufruf kommt. Sollten wir die Anforderung besitzen, bestimmte Kunden möglichst gar nicht zu drosseln (z. B. weil sie ein Premiumabonnement besitzen), dann kommen API Keys ins Spiel. Dabei definieren wir Nutzungspläne, die wir mit Aufruflimits versehen. Diese Limits können wir wieder auf Ebene unseres API, einer Stage oder einzelner Methoden definieren. Wir legen geringere Limits für unsere Basiskunden und höhere für unsere Premiumkunden fest. Im Anschluss erzeugen wir für unsere Kunden API Keys, die wir mit den entsprechenden Nutzungsplänen verknüpfen.

Auf der AWS re:invent 2019 wurde das neue Amazon API Gateway HTTP API vorgestellt [8], das, verglichen mit dem REST API, noch einen reduzierten Funktionsumfang hat, aber eine geringere Latenz besitzt und circa 70 Prozent geringere Kosten verursacht. Über die nächsten Monate werden bekannte Funktionen aus dem Amazon API Gateway REST API auch für das HTTP API zur Verfügung gestellt. Also sollte man immer mal wieder vorbeischauen.

Optimierung unserer Funktionen

Nicht neu für uns ist, dass sich die Größe unserer Funktion und deren Abhängigkeiten direkt auf die Dauer der ersten Ausführung unserer Lambda-Funktion auswirkt, den so genannten Kaltstart. Darüber hinaus können aber auch die Wahl der Frameworks, die Wiederverwendung von HTTP- oder Datenbankverbindungen und ihre Konfiguration (Keep-alive) und weitere Faktoren eine signifikante Auswirkung auf die Ausführungsgeschwindigkeit unserer Funktion haben. Um hier einen schnellen und guten Überblick zu erhalten, empfehle ich die AWS re:invent Präsentation „Best practices for AWS Lambda and Java“ [9].

Sollte sich die Kaltstartzeit trotz aller Bemühungen nicht auf ein annehmbares Maß (bezogen auf unsere Anforderungen) reduzieren lassen, so können wir von der Möglichkeit Gebrauch machen, unsere AWS-Lambda-Funktionen von AWS vorwärmen zu lassen [10]. Die Kaltstartzeit für so vorgewärmte Funktionen beträgt weniger als 100 ms. Zuvor sollten wir aber abwägen, ob z. B. die Betrachtung unserer Latenz bezogen auf 99,9 Prozent der Aufrufe ausreichend ist, oder ob eine Betrachtung auf 99,99 Prozent gefordert oder notwendig ist und so die gegebenenfalls anfallenden Mehrkosten gerechtfertigt werden können. Die konkreten Prozentzahlen hängen natürlich vom Aufrufpattern unseres API ab.

Wenn wir unsere AWS-Lambda-Funktion nach AWS deployen, müssen wir den Arbeitsspeicher dieser Funktion konfigurieren. Dabei reicht die Bandbreite von 128 MB bis 3 GB. Diese Einstellung bestimmt aber auch, wie viel CPU- und Netzwerkkapazität der Funktion zur Verfügung gestellt wird. Da sich diese Einstellung direkt auf die Ausführungsgeschwindigkeit und die Kosten unserer Funktion auswirkt, sollten wir es richtig machen. Doch wie ermitteln wir die optimalen Einstellungen für unsere AWS-Lambda-Funktion?

Mit der AWS-Lambda-Power-Tuning-Anwendung [11] aus dem AWS Serverless Application Repository (AWS SAR) [12], kann das sehr schnell und einfach ermittelt werden (Abb.1). Die Anwendung testet unsere Lambda-Funktion mit den von uns vorgegebenen Arbeitsspeichereinstellungen und kann sie visualisieren. Wir können dann sehr einfach entscheiden, was die optimale Konfiguration bezogen auf die Ausführungsgeschwindigkeit oder unsere Kosten ist.

Abb. 1: Visualisierung unseres Testergebnisses

Abb. 1: Visualisierung unseres Testergebnisses

Eine elementare Regel in Bezug auf die Sicherheit unserer Anwendung ist, dass wir unseren AWS-Lambda-Funktionen nur die Zugriffsrechte geben, die sie auch wirklich benötigen, und nicht mehr. Anstatt unserer AWS-Lambda-Funktion alle Rechte auf alle Amazon-DynamoDB-Tabellen in unserem Account zu geben (einschließlich der Berechtigung, Tabellen zu löschen), geben wir ihr nur das Zugriffsrecht auf diese eine Tabelle und nur für genau die Operation, die sie benötigt – z. B. das Lesen eines Eintrags mittels seines Primärschlüssels (Listing 1).


{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "GetItemAPIActionsOnOrders",
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem"
      ],
      "Resource": "arn:aws:dynamodb:eu-central-1:123456789012:table/Orders"
    }
  ]
}

Aus genau diesem Grund nutzen wir auch eine separate AWS-Lambda-Funktion pro Methode, die unser Amazon API Gateway veröffentlicht – sogenannte Single Purpose Functions. Nur so können wir das Least-Privilege-Prinzip umsetzen.

Im Gegensatz zu den sogenannten Fat Functions oder Monolithic Functions hilft uns dieser Ansatz auch, unsere Funktionen klein zu halten (weniger Abhängigkeiten) und die Vergabe des Arbeitsspeichers zu optimieren.

Standardmäßig können wir in einem AWS-Account und in einer AWS-Region bis zu 1 000 AWS-Lambda-Funktionen gleichzeitig ausführen. Dieses Limit gilt für alle Funktionen, die wir deployt haben. Darüber hinaus haben wir die Möglichkeit, die Nebenläufigkeit pro Funktionen zu beschränken (Kasten: „Nebenläufigkeit gegenüber Transaktionen pro Sekunde (TPS)“). Das ist notwendig, wenn unsere AWS-Lambda-Funktion mit einem Backend kommunizieren muss, das nicht in der Lage ist, Hunderte gleichzeitige Anfragen zu verkraften. Diese Beschränkung dient gleichzeitig auch als Kapazitätsreservierung. So können wir sicherstellen, dass wir für bestimmte Funktionen immer eine definierte Kapazität vorhalten, auch wenn andere Funktionen unsere restliche Kapazität aufbrauchen (Noisy-Neighbour-Problem). Das erhöht die Zuverlässigkeit unserer Anwendung.

Nebenläufigkeit gegenüber Transaktionen pro Sekunde (TPS)

Nicht selten verwechseln Kunden die Begrifflichkeiten Nebenläufigkeit (Concurrency) und Transaktionen (bzw. Requests) pro Sekunde. AWS Lambda hat standardmäßig eine Nebenläufigkeit von bis zu 1 000 Funktionen pro AWS-Account und AWS-Region. Soll die maximale Anzahl der Transaktionen pro Sekunde ermittelt werden, die damit erreicht werden kann, wird die durchschnittliche Ausführungszeit der Funktion benötigt. Beträgt diese genau eine Sekunde, so können wir bei einer Gleichverteilung der Aufrufe 1 000 Transaktionen pro Sekunde erreichen. Beträgt die Ausführungszeit hingegen nur 100 ms, können wir bis zu 10 000 Transaktionen pro Sekunde bewältigen. Bei einer Ausführungszeit von 10 Sekunden allerdings nur 100 Transaktionen pro Sekunde.

Im vorigen Artikel haben wir Quarkus für die Optimierung von containerbasierten Workloads genauer betrachtet. Quarkus ist aber nicht auf die Nutzung in Containern oder virtuellen Maschinen limitiert, sondern kann auch sehr einfach genutzt werden, um AWS-Lambda-Funktionen zu implementieren. Hier profitieren wir ganz besonders von der Möglichkeit, ein natives Image zu erstellen. Damit können wir eine Lambda Custom Runtime als Laufzeitumgebung wählen und auf eine JVM (~80 MB) verzichten. Ein vollständiges Beispiel ist unter [13] zu finden.

Eine native AWS-Lambda-Funktion zeichnet sich durch eine reduzierte Kaltstartzeit und durch einen geringeren Speicherverbrauch aus. So lässt sich der Kaltstart einer AWS-Lambda-Funktion, die parallel auf Amazon S3 und Amazon DynamoDB zugreift, auf ca. 400 Millisekunden reduzieren – und das mit einem geringeren Hauptspeicherbedarf (Abb. 2). Das führt zu geringeren Kosten und einem gleichmäßigeren Kundenerlebnis, da die Kaltstarts weniger stark ins Gewicht fallen. Unter [14] finden wir etliche Beispiele, die uns helfen, das Kaltstartverhalten einer Spring-PetClinic-Anwendung von ursprünglich 11,5 Sekunden Schritt für Schritt auf 410 Millisekunden zu reduzieren. Weitere Best Practices hat das AWS-Lambda-Team auf seiner Webseite dokumentiert [15].

Abb. 2: Visualisierung eines Kaltstarts einer nativen AWS-Lambda-Funktion mit AWS X-Ray

Abb. 2: Visualisierung eines Kaltstarts einer nativen AWS-Lambda-Funktion mit AWS X-Ray

Optimierung unserer Datenbank

Amazon DynamoDB [16] stellt uns vor die Wahl, die für unsere Anwendung notwendigen Lese- und Schreiboperationen zu konfigurieren – sogenannte Read Capacity Units (RCUs) und Write Capacity Units (WCUs). Das können wir manuell über einen API-Aufruf durchführen oder automatisch mittels AWS Auto Scaling [17], um einer vorgegebenen Zielauslastung zu entsprechen. AWS Auto Scaling erhöht oder reduziert die aktuell konfigurierten RCUs oder WCUs basierend auf der aktuellen Auslastung und im Rahmen unserer vorgegebenen Grenzen, um unseren Zielvorgaben zu entsprechen. Während das für viele Workloads schon sehr gut geeignet ist, ist es keine optimale Lösung für Workloads mit häufig schnell und stark ansteigender oder fallender Last. Auch für Workloads, die über längere Zeit nicht genutzt werden (z. B. Entwicklungs- und Testumgebungen in der Nacht), ist die On-Demand-Konfiguration besser geeignet. Hier wird pro Datenbankanfrage abgerechnet. Unsere Amazon-DynamoDB-Tabelle wird initial so für uns bereitgestellt, dass sie mindestens 4 000 Schreib- und 12 000 Leseoperationen pro Sekunde unterstützt. Grundsätzlich richtet sich die Bereitstellung der Lese- und Schreibkapazität nach unserer bisherigen Lastspitze, von der ausgehend Amazon DynamoDB die doppelte Lese- und Schreibkapazität bereitstellt. Sollten wir anhaltend eine höhere Last als unsere bisherige Lastspitze erhalten, wird diese zu unserer neuen Lastspitze und Amazon DynamoDB stellt sofort die doppelte Kapazität zur Verfügung. Es kann jedoch zu einer Drosselung kommen, wenn wir innerhalb von 30 Minuten das Doppelte unseres vorherigen Spitzenwertes überschreiten. Für neue Anwendungen mit geringem oder unbekanntem Lastaufkommen ist das in der Regel die kostengünstigere und zuverlässigere Wahl.

Das von AWS bereitgestellt Software Development Kit (SDK) für Amazon DynamoDB führt standardmäßig einen konsistenten Lesezugriff durch. Konsistenz innerhalb einer Amazon-DynamoDB-Tabelle wird normalerweise in unter einer Sekunde hergestellt. Liegt zwischen unserem Schreib- und folgendem Lesezugriff mehr als eine Sekunde, oder kann unsere Applikation mit eventuell konsistenten Daten umgehen, dann sollten wir unsere Lesezugriffe entsprechend konfigurieren. Das kann zu einer gesteigerten Verfügbarkeit und Performance führen, da jetzt ein beliebiger Server die Anfrage beantworten kann. Zusätzlich halbieren wir unsere Kosten verbunden mit Lesezugriffen, da sie nur die Hälfte der konsistenten Leseanfragen kosten.

Es gibt noch etliche weitere Möglichkeiten, die Geschwindigkeit und die Kosten unserer Amazon DynamoDB zu optimieren. Da darauf einzugehen den Umfang eines eigenständigen Artikels hätte, verweisen wir hier auf die dokumentierten Best Practices [18].

Optimierung unserer Monitoringumgebung

Auch hier können wir optimieren, vor allem unsere Kosten. Die Logausgaben unserer AWS-Lambda-Funktionen werden automatisch nach Amazon CloudWatch Logs [19] geschrieben. Das ist bequem und erlaubt uns, sehr einfach mit Amazon CloudWatch Logs Insights [20] einen gesamtheitlichen Blick auf alle Logeinträge unserer AWS-Lambda-Funktionen zu erhalten. Allerdings ist die Aufbewahrung dieser Logeinträge im Standard unbeschränkt – sie werden also niemals gelöscht. Das führt über die Zeit zu steigenden Kosten für Amazon CloudWatch Logs und ist oft unnötig, insbesondere in Entwicklungs- und Testumgebungen. Auch dafür existiert im AWS SAR eine nützliche Anwendung [21], die die Einstellungen für alle existierenden und neu hinzukommenden Amazon-CloudWatch-Logs-Gruppen auf einen für uns sinnvollen Wert setzen kann (z. B. 14 Tage). Sollten wir unsere Logdateien von Produktionsumgebungen länger aufheben müssen, empfiehlt sich aufgrund der günstigeren Speicherkosten der in Amazon CloudWatch Logs integrierte Export nach Amazon S3 [22]. Über eine Amazon S3 Lifecycle Policy [23] können wir definieren, wann diese Logfiles in noch günstigere Speicherklassen verschoben (z. B. Amazon Glacier [24]) oder gelöscht werden sollen.

Im vierten Artikel dieser Serie hatten wir auch kurz die Möglichkeit angesprochen, eigene geschäftsrelevante Metriken in Amazon CloudWatch als Custom Metrics zu erzeugen (z. B. das Volumen der bewegten Geldmenge in einer Finanzapplikation). Wenn wir das direkt über den Aufruf des Amazon CloudWatch Metric API durchführen, führt dieser zusätzliche HTTP Request zu einer größeren Latenz in unserer Anwendung und verursacht bei intensiver Nutzung spürbare Kosten. Seit einigen Monaten besteht jedoch eine weitaus bessere Möglichkeit, eigene Metriken zu erstellen. Amazon CloudWatch Embedded Metric Format [25] erlaubt es uns, Logeinträge in einem vordefinierten Format nach Amazon CloudWatch Log zu schreiben, aus denen dann automatisch die Metriken extrahiert und nach Amazon CloudWatch Metric gepostet werden. Das reduziert die Latenz, da das Schreiben des Logeintrags viel schneller ausgeführt wird, und es hilft uns, weitere, unnötige Kosten zu sparen (Listing 2).


{
  "_aws": {
    "Timestamp": 1576064416496,
    "CloudWatchMetrics": [{
      "Namespace": "ecommerce-cart",
      "Dimensions": [["Environment"], ["Environment", "CategoryId"]],
      "Metrics": [
        {"Name": "PriceInCart", "Unit": "None"},
        {"Name": "QuantityInCart", "Unit": "None"}
      ]
    }]
  },
  "Environment": "prod",
  "CategoryId": "bca4cec1",
  "PriceInCart": 100,
  "QuantityInCart": 2,
  ...
  "Message": "Added 2 items 'a23390f3' to cart '58dd189f'",
  "customerId": "e57a3b12a0"
}

Ein weiterer positiver Aspekt der Nutzung des Amazon CloudWatch Embedded Metric Format ist, das wir damit automatisch auch strukturierte Logeinträge in JSON erzeugen, die sich viel besser weiterverarbeiten und visualisieren lassen (z. B. mit Amazon CloudWatch Log Insights). Aber auch komma- oder tabseparierte Logeinträge lassen sich so einfach auswerten. Nehmen wir zum Beispiel an, wir möchten ermitteln, wie groß der Anteil der Kaltstarts unserer Funktion im Verhältnis zu allen Ausführungen ist. Nach der Ausführung einer AWS-Lambda-Funktion wird automatisch ein Logeintrag wie der folgende erzeugt:

REPORT RequestId: 4a88e6f5-b6a9-4307-a1ed-141758487c8f Duration: 1567.73 ms Billed Duration: 1600 ms Memory Size: 512 MB Max Memory Used: 97 MB Init Duration: 355.15 ms

Dabei wird Init Duration nur gemeldet, wenn es sich um die erste Ausführung einer AWS-Lambda-Funktion handelt, also einen Kaltstart. Das erlaubt uns, die in Listing 3 gezeigte Abfrage in Amazon CloudWatch Log Insights zu definieren (Abb.3).


filter @type = "REPORT"
| stats
  sum(strcontains(
    @message,
    "Init Duration"))
  / count(*)
  * 100
  as coldStartPercentage,
  avg(@duration)
  by bin(5m)
Abb. 3: Visualisierung unserer AWS-Lambda-Funktions-Kaltstarts pro 5 Minuten

Abb. 3: Visualisierung unserer AWS-Lambda-Funktions-Kaltstarts pro 5 Minuten

Fazit

In diesem siebten und letzten Teil unserer Artikelserie haben wir betrachtet, wie wir auch serverlose Applikationen im Bezug auf Geschwindigkeit, Zuverlässigkeit, Sicherheit und Kosten optimieren können. Neben den hier erläuterten Optionen hat AWS 46 weitere Best Practices für alle Arten von Architekturen im AWS Well-Architected Framework [26] dokumentiert. Im Dokument AWS Serverless Applications Lens [27] wird mit neun weiteren Fragen speziell auf serverlose Architekturen eingegangen. Seit Ende 2018 haben wir die Möglichkeit, das AWS Well-Architected Tool [28] in unserer AWS Console zu nutzen, um unsere Applikationen einem Review zu unterziehen – ohne jegliche Kosten. Am Ende des Reviews erhalten wir neben einer Zusammenfassung sehr detaillierte Hinweise auf kritische oder hochkritische Probleme im Bezug auf Sicherheit, Zuverlässigkeit, Kostenoptimierung, Leistung und Effizienz oder betriebliche Exzellenz. Durch zahlreiche Links zu Videos, Blogposts oder zur AWS-Dokumentation werden wir dabei unterstützt, diese Probleme schnell und effektiv zu beheben. Das sollte auch unser nächster Schritt nach dem Lesen und Nachvollziehen dieser Artikelserie sein. Entscheider sollten auf diese Review bestehen, bevor neue Applikationen in den produktiven Betrieb übernommen werden. Denn sie hilft, zu verstehen, wo Schwachstellen und Chancen in der Applikation liegen, sodass wir diese in unser Backlog aufnehmen und entsprechend priorisieren können.

 

Geschrieben von
Christian Müller
Christian Müller
In seiner Rolle als Senior Solutions Architekt bei Amazon Web Services (AWS) in Frankfurt hilft Christian Müller seinen Kunden, das volle Potenzial der AWS Cloud zu nutzen, um sie so noch erfolgreicher zu machen. Seine besonderen Interessen liegen dabei in den Bereichen Messaging, Event-getriebene Architekturen und Serverless.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: