Über unsMediaKontaktImpressum
Elisabeth Schulz 08. Juni 2021

Serverless und doch Metal: AWS Lambda mit Rust

Serverless-Entwicklung hat, seit AWS Lambda allgemein verfügbar wurde, eine sprunghafte Entwicklung erlebt. Die angenehme und einfache Möglichkeit, einfach "nur den Code" zu entwickeln und gar keine Rücksicht auf Details der Laufzeitumgebung nehmen zu müssen, ist (neben Kubernetes und anderen Plattformen um Container zu verwalten) eine der entscheidenden Innovationen der letzten Jahre.

Allerdings werden in der Serverless-Umgebung meistens eher maschinenferne Sprachen wie node.js oder Java verwendet. In diesem Artikel beschreibe ich, wie man eine sehr ausdrucksstarke und doch maschinennahe Sprache für AWS Lambda verwenden kann: Rust. Ich werde in diesem Artikel keine grundlegende Einführung in Rust einschließen. Wo aber Besonderheiten zu beachten sind, werde ich darauf hinweisen, wie sie mit der Sprache interagieren.

Erste Schritte mit Lambda Custom Runtimes

AWS Lambda unterstützt Rust erst einmal nicht "out of the box". Lambda bietet neben vorbereiteten Runtimes für einige Sprachen aber auch die Möglichkeit, eine komplett eigene Runtime zu definieren [1]. Solch eine Runtime definiert sich relativ einfach: Ein ZIP-Archiv, das eine ausführbare Datei namens bootstrap beinhaltet. Diese Datei wird vom Lambda-System aufgerufen und hat die Verantwortung, über eine spezielle Schnittstelle von AWS Aufgaben abzurufen und Resultate zurückzuliefern.

Die offizielle Dokumentation zu solchen "custom runtimes" liefert hier schon ein Beispiel, das mittels curl nacheinander Aufrufe vom Laufzeitsystem annimmt und diese an einen Handler weiterleitet. Als Handler wäre hier also unser Rust-Programm eine gute Wahl. In Listing 1 ist das beispielhaft aufgezeigt:

Listing 1:

#!/bin/sh

while true
do
    EVENT_DATA=$(curl --silent --location --dump-header ./headers "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
    INVOCATION_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id ./headers | tr -d '[:space:]' | cut -d: -f2)

    RESPONSE=$(echo $EVENT_DATA | handler_program)

    # Send the response to Lambda runtime
    curl --silent --request POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$INVOCATION_ID/response" --data "$RESPONSE"
done

Rust wird typischerweise auf Maschinencode kompiliert, und hat folglich kein "write once, run anywhere"-Prinzip. Wir müssen stattdessen für das individuelle Host-System bauen (also passend zu Prozessor, Kernel und Systembibliothek). AWS verwendet als Laufzeitumgebung für Lambda-Handler eine Linux-Distribution mit musl als C-Library [2]. Diese Bibliothek erfreut sich gerade bei sehr schmalen oder Embedded-Systemen hoher Beliebtheit und ist im Grunde ein vollwertiger Ersatz für die deutlich größere glibc. Allerdings ist sie nicht binärkompatibel mit der GNU-Library.

Daher muss ein eigenes Target für den Build verwendet werden: x86_64-unknown-linux-musl. Hierbei handelt es sich um eine Variante der Rust-Distribution die im sogenannten "Tier 2" beheimatet ist: Distributionen auf dieser Support-Stufe werden vom Rust-Projekt zur Verfügung gestellt, automatisch gebaut, haben aber nicht die automatische QA, die "Tier 1"-Targets genießen.

Dieses Target kann mittels rustup target add x86_64-unknown-linux-musl installiert werden. Sobald das erfolgreich war, kann man mittels cargo build --target x86_64-unknown-linux-musl ein für diese Umgebung zugeschnittenes Binary generieren. Dieses Programm erhält dann, wenn wir uns an das Shellskript oben halten, seinen input über stdin und liefert stdout als Ergebnis zurück.

Wenn wir ein so erzeugtes Binary mit einem Bootstrap-Shellscript zusammen in ein ZIP-Archiv verpacken, dann haben wir ein prinzipiell funktionsfähiges Lambda-Programm in Rust geschrieben. Allerdings ist die Interaktion mittels curl zur Ein-/Ausgabe und das immer wieder Neustarten des Handlers doch etwas unpraktisch.

Die (in)offizielle Rust Runtime Library

Vielen Leser:innen juckt es jetzt vielleicht in den Fingern, eine Lösung zu schreiben, die diese Probleme eleganter anpackt. Named pipes oder input redirection im Shellscript oder die REST-Schnittstelle in Rust selbst einzubauen wären hier valide Ansätze. Zum Glück ist dies aber ein bereits gelöstes Problem: AWS stellt eine im Rahmen ihrer AWS Labs entwickelte Bibliothek zur Verfügung, die diese Aufgaben komplett kapselt. Diese Library nennt sich lambda_runtime und wird beständig weiterentwickelt [3]. Für diesen Artikel beziehe ich mich auf die Version 0.46, frühere Versionen waren teilweise etwas weniger komfortabel zu benutzen.

Wenn diese Library verwendet wird, kann das Bootstrap-Skript im Grunde vollständig entfallen. Stattdessen übernimmt die Funktion lambda_runtime::run das Annehmen neuer Aufgaben und das Melden von Ergebnissen.

Diese Funktion ist einen näheren Blick wert:

pub async fn run<A, B, F>(handler: F) -> Result<(), Error>
where
    F: Handler<A, B> + Send + Sync + 'static,
    <F as Handler<A, B>>::Fut: Future<Output = Result<B, <F as Handler<A, B>>::Error>> + Send + 'static,
    <F as Handler<A, B>>::Error: fmt::Display + Send + Sync + 'static,
    A: for<'de> Deserialize<'de> + Send + Sync + 'static,
    B: Serialize + Send + Sync + 'static,

Diese Deklaration mag erst mal einschüchternd wirken, aber wenn wir sie in Ruhe lesen, dann erklärt sich doch recht schnell, was dabei passiert:

  1. Die Funktion ist async, sollte also in einem async-Context (wie beispielsweise einer tokio:main-Funktion) gestartet werden.
  2. Sie liefert kein "hauptsächliches" Resultat, kann aber scheitern.
  3. Sie erwartet einen Parameter vom Typ F, der Handler<A,B> implementieren muss.
  4. Der in Handler deklarierte Parameter Fut muss ein Future sein, das ein Result<B> produziert.
  5. Der Fehler im Result muss der gleiche Fehler sein, der unter Handler::Err deklariert wurde.
  6. Der Typ A, welcher die Eingabedaten für diese Handler repräsentiert, muss Deserialize implementieren.
  7. Der Typ B, welcher die Ausgabedaten für diesen Handler repräsentiert, muss Serialize implementieren.
  8. Quasi alle oben genannten Typen müssen Threadsafe sein (Sync, Send, 'static als unbeschränkte Lifetime)

Also kann man zusammenfassend sagen: Ein Handler muss Eingaben von einem deserialisierbaren Typ annehmen können und als Resultat einen serialisierbaren Typen liefern, wobei die tatsächliche Behandlung asynchron abläuft.

Warum asynchron, wenn Lambda-Handler doch nur einen Request gleichzeitig erhalten? Hier gibt es zwei Gründe: Zum Einen kann es trotzdem Sinn machen, einige Bearbeitungsschritte zu parallelisieren (z. B. Speichern von Metadaten und "echten" Daten), zum Anderen sind viele Bibliotheken in Rust nativ auf dem async-Framework implementiert – und die Support-Library nutzt diese. Asynchrones handling integriert sich also natürlicher in den Programmablauf.

Die Signatur von run mag erst einmal einschüchternd wirken, aber der natürlichste Kandidat, den Handler zu implementieren, ist eine einfache globale Funktion mit folgender Signatur:

async fn handler(input: InputType, ctx: Context) -> Result<OutputType, Error>

Solche Funktionen können mittels des Helpers handle_func aus der Lambda-Support-Library in Handler "aufgerüstet" werden, so dass in der Praxis damit quasi das einfache und bequeme Programmiermodell einer Sprache wie Java wiederhergestellt ist, mit lediglich folgendem "Boilerplate"-Code zum Start:

#[tokio::main]
async fn main() -> Result<(), Error> {
    let func = handler_fn(handle);
    lambda_runtime::run(func).await
}

Intern verwendet die Runtime so gängige crates wie hyper, tokio und serde, die in den meisten Programmen ohnehin ihre Anwendung finden werden. Funktions-Handler wie hier beschrieben sind stateless. Für komplexere Wünsche ist natürlich eine manuelle Implementierung von Handler immer noch möglich.

Das so erzeugte binary könnte im Prinzip komplett standalone funktionieren, ohne dass ein weiteres Bootstrap-Shellscript notwendig ist – solange es den Namen "bootstrap" trägt und ausführbar ist, wird Lambda es akzeptieren.

Clients für andere Services einbinden

Fast keine Serverless-Anwendung kann für sich alleine stehend größere Aufgaben erledigen. Fast immer wird eine externe Schnittstelle – beispielsweise zur Datenbank, aber oft auch zu REST-APIs – nötig. Hier ergibt sich eine besondere Herausforderung: Die typischerweise verwendeten Libraries wie hyper oder rusoto können nicht zu 100 Prozent out-of-the-box verwendet werden. Stattdessen scheitert der build oftmals mit einem Fehler, der ungefähr so lautet:

Could not find directory of OpenSSL installation, and this `-sys` crate cannot proceed without this knowledge

Der Grund hierfür ist einfach zu verstehen, aber nicht ganz so einfach zu beheben: Der Fehler legt nahe, dass das lokale Build-Environment kein OpenSSL hat, was wahrscheinlich zumindest beim Build unter Linux nicht wahr ist. Aber da wir ja einen cross-compile vornehmen, ist das openSSL im Buildsystem nicht ausreichend. Es müsste auch in der Zielumgebung installiert und verfügbar sein.

Es gäbe jetzt mehrere Möglichkeiten, damit umzugehen: Zum einen könnten die notwendigen Systembibliotheken in einem Layer installiert werden, um so das System an die Anwendung anzupassen. Allerdings ist es in vielen Fällen einfacher, auf die Library zu verzichten. Das rustls-Projekt stellt eine eigene Implementierung von TLS zur Verfügung, die ohne solche dependencies auskommt.

Besonders die rusoto-Bibliothek profitiert davon. Diese Bibliothek existiert wahrscheinlich in vielen Projekten, die auf Lambda deployed werden, denn sie ist in Rust das Binding für AWS-APIs. Diese Library verwendet unter der Haube die hyper-Bibliothek, die zum Glück auch mit dem rust-nativen TLS konfiguriert werden kann, wie im folgenden Listing gezeigt:

rusoto_core = { version = "0.46", features = ["rustls"], default-features = false }
rusoto_dynamodb = { version = "0.46", features = ["rustls"], default-features = false }

Auch viele andere Crates können über Features so konfiguriert werden, dass sie diese alternative TLS-Implementierung nutzen, anstelle auf die Systembibliothek zu verweisen. Dies ist üblicherweise einfach durch Feature Flags in der Cargo.toml möglich.

Wie viel bringt es wirklich?

Rust ist um seiner selbst willen schon eine sehr interessante Sprache, die breitere Nutzung meiner Ansicht nach durchaus verdient. Allerdings sind "Schönheitspreise" eher schwierig an Entscheider zu vermitteln. Daher habe ich für diesen Artikel ein kleines Szenario für eine zwar einfache, aber durchaus realistische Anwendung, die Rust auf Lambda erproben soll aufgestellt. Im Folgenden werde ich dieses Szenario kurz vorstellen und über ein Experiment hoffentlich Aufschluss über die realistisch möglichen Einsparpotentiale und zusätzlichen Probleme zu liefern. 

Test-Setup

Um die Performance zu messen, habe ich ein praxisnahes Beispiel ausgewählt und exemplarisch in Java und in Rust umgesetzt [4]. Hierbei handelt es sich um eine kleine Anwendung, wie sie ein fiktives soziales Netzwerk vielleicht benötigt. In diesem sozialen Netzwerk können Benutzer Dateien hochladen – und da die Benutzer häufig "reaction gifs" nutzen, wird durchaus auch eine Datei mehrfach hochgeladen. Diese Dateien dann jeweils getrennt zu speichern, ist ein unnötiger Aufwand, vor allem weil populäre Bilder ohne weiteres mehrere tausendmal auftreten können.

Also soll mittels Lambda eine Duplikateliminierung durchgeführt werden. Die Lambda-Funktion hat folgende Aufgaben:

  • Neu angelegte Dateien per SHA-512 hashen.
  • In einer DynamoDB-Tabelle prüfen, ob für diesen hash schon eine Datei existiert.
  • Falls ja, die Datei löschen und einen Alias-Eintrag erstellen.
  • Falls nein, diese Datei als Referenz speichern und ein Alias auf sich selbst erstellen.

Um die Performance dieser Lösungen zu ermitteln, habe ich ein Script verwendet, das auf 6 EC2-Instanzen parallel lief und dort jeweils 50 Dateien mit je 10MB zufälligem Inhalt erstellte. Danach wurde 1000-mal je eine dieser Dateien zufällig gewählt und sie unter einem neuen Dateinamen nach S3 hochgeladen. Dadurch ergab sich eine etwa 17 Minuten lange Teststrecke, die sowohl Anlauf-, Abkling- und Dauerlast-Effekte darstellen konnte.

Ergebnisse

Die Ergebnisse (Tabelle 1) können sich sehen lassen. Bei vergleichbarem Entwicklungsaufwand (die Rust-Bibliothek für DynamoDB hat leider keinen nativen Support für Serialisierung mit serde, so dass ich dies nachrüsten musste) konnten die beiden Sprachen das gestellte Problem jeweils stimmig lösen. Der Deployment-Prozess mit der AWS CLI gestaltete sich unproblematisch und die Messung mittels Cloudwatch einfach und angenehm. Erhebliche Vorteile im Entwicklungsaufwand sind nicht festzustellen und der Code ist in beiden Fällen etwa gleich komplex.

Bemerkenswert ist hierbei, dass die Java-Anwendung deutlich mehr Speicher benötigt, als die Rust-Anwendung, so dass ein Vergleich "auf gleicher Höhe" schwierig wird. Das bedingt auch die scheinbar höhere Performance der Java-Anwendung, die im Durchschnitt einen Request innerhalb von 400 ms abarbeiten kann, während die Rust-Anwendung 700 benötigt.

Dieses Ergebnis erscheint auf den ersten Blick nicht intuitiv, da die maschinenkompilierte Sprache normalerweise die höhere Performance haben "sollte". Auf den zweiten Blick jedoch wird klar, woher der Effekt kommt: Lambda weist Rechenzeit proportional zum reservierten Speicher zu. Dadurch erhält das Java-Programm (4x Speicher) auch die 4-fache Rechenzeit. Bei Bereitstellung von mehr Arbeitsspeicher (und damit verbunden auch höheren Kosten) verschwindet der Effekt erwartungsgemäß, und kehrt sich sogar um. Einen gewissen Restsatz wird es aber immer geben, um den Download der Datei zu realisieren, da mehr Rechenzeit keine Vorteile bei der Latenz zu S3 bietet und die zu hashende Datei ja auch erst einmal die Lambda-Funktion erreichen muss.

MetrikRustJava
Lines of Code214 (139 ohne DynamoDB-Glue)129
Entwicklungszeit (h)54
Deployment-ZIP (kb)254911090
min. Arbeitsspeicher (MB)128 (Untere Grenze durch AWS Lambda vorgegeben, Programm nutzt deutlich weniger)512
max. Laufzeit (ms)131210428
durchschn. Laufzeit (ms)717396
durchschn. Kosten / MAufruf ($)15293379
Faktor Maximallaufzeit / Durchschn.1,8226,33

Auch augenfällig sind hier die extremen Unterschiede in der Maximal- gegenüber der Durchschnittslaufzeit. Dies ist zwei Faktoren geschuldet, die die JVM kaum abstellen kann: Kaltstart-Kosten und Garbage Collection.

Kaltstart-Kosten treten dadurch auf, dass die JVM erst einmal relativ lange (für Lambda-Verhältnisse) nur mit sich selbst beschäftigt ist: Bootstrapping, Classloading usw. werden auf den ersten Request angerechnet, so dass dieser extrem teuer ist. Spätere Requests werden dann zunehmend billiger und performanter, auch aufgrund des JIT. Rust dagegen hat von Beginn an den gleichen Maschinencode zur Verfügung. Die Kaltstart-Kosten sind hier also deutlich geringer, es  kommen nur Aufgaben wie TLS-Handshakes in Betracht.

Garbage Collection is den meisten Leser:innen wohl ein Begriff: Die leider teilweise unvermeidbaren Pausen und unvorhersehbaren "Hänger", wenn die JVM den Speicher aufräumen muss. Diese Pausen hat Rust aufgrund seiner eigenen, deterministischen Speicherverwaltung gar nicht.

Zusammenfassung

Serverless-Programmierung wird oft mit den beiden Platzhirschen (JVM und node.js) untrennbar in Verbindung gesehen, aber dem ist absolut nicht so. Im Gegenteil: gerade in den unteren Bereichen der Runtime existiert eine Chance, mittels einer geschickten Sprachwahl echtes Geld zu sparen.

Gerade in Software, die mit extrem hohen Aufrufzahlen umgehen soll, ist damit eine High-Performance-Sprache auf jeden Fall einen zweiten Blick wert und gegebenenfalls auch ein Pilotprojekt. Abgesehen von einigen Kinderkrankheiten ist die Integration einfach, leistungsstark und nicht mit viel zusätzlichem Aufwand verbunden – die evtl. leicht längere Entwicklungszeit wird durch reduzierte Kosten für die Ausführung mehr als kompensiert.

Gerade wenn vorhersehbare Laufzeiten eine große Rolle spielen oder es wirklich auf Millisekunden ankommt, würde ich die Sprache sogar als empfohlen ansehen.

Lambda-Handler in Rust umzusetzen macht also durchaus Sinn, gerade wenn es sich um eine High-Frequency-Lambda handelt, die eher in höherer Concurrency läuft. Die Support-Library erlaubt es dabei, ein Programmiermodell zu erreichen, das im Komfort den "nativen" Runtimes nicht viel nachsteht. Integration in bestehende Frameworks ist durch den Support des Async-Frameworks unproblematisch. Für den Spezialfall, dass die Lambda als Handler für APIGateway oder einen ALB genutzt wird, steht mit der lambda_http crate eine noch weitergehende Integration ins Tooling bereit [5].

Autorin
Das könnte Sie auch interessieren

Neuen Kommentar schreiben

Kommentare (0)