Einfaches Caching für Serverless Apps

Vor Kurzem waren wir in der Situation, dass wir in einem API-Endpunkt XML-Dateien abrufen und verarbeiten mussten. Die Dateien werden von verschiedenen Server bereitgestellt, die nicht unter unserer Kontrolle sind. Diese sind unterschiedlich gut verfügbar und antworten unterschiedlich schnell. Und manchmal auch überhaupt nicht.

Der naive Ansatz, bei jedem Request an unserer API die Datei abzurufen scheidet aus. Die Latenz wäre nicht vorhersehbar und kurzzeitige Ausfälle der fremden Server würden zu Fehlern in unserer API führen.

Wir müssen das Ergebnis eines erfolgreichen Abrufs der Datei also zwischenspeichern. Im folgenden wollen wir einmal die verschiedenen Möglichkeiten beleuchten, die uns zur Verfügung stehen.

Lokaler Cache

Die einfachste Möglichkeit ist, das Ergebnis einer erfolgreichen Abfrage im Speicher zu behalten. Alle gängigen Programmiersprachen haben hierfür verschiedene Bibliotheken mit erprobten Implementierungen. In Java-Welt ist z.B. Caffein weit verbreitet.

Lokale Caches zeichnen sich durch schnelle Zugriffszeiten aus. Weil alle Zugriffe auf derselben Maschine passieren, gibt es keine Netzwerklatenz. Außerdem ist der Cache immer verfügbar, solange der Speicher der Maschine nicht vollläuft.

Allerdings laufen heutzutage oftmals viele Instanzen einer Applikation parallel (horizontale Skalierung) und sind wesentlich kurzlebiger als klassische monolithische Anwendungen.

Dies gilt für Docker-Container oder (wie in unserem Fall) Lambda-Funktionen. Jede Instanz einer Applikation verwaltet ihren eigenen Speicher und kann somit nicht auf den Cache einer anderen Instanz zugreifen. Im schlimmsten Fall werden dieselben Dateien in jeder Instanz aufs neue heruntergeladen und in separaten Caches gespeichert.

Remote Cache

Wenn es viele Instanzen einer Applikation gibt, die auf denselben Cache zugreifen sollen, kommt man nicht über die Benutzung einer verteilten Caching-Lösung herum. Hier gibt es viele Optionen: Redis, Memcached, DynamoDB mit TTL sind nur einige.

Diese Services bieten erprobte Caching-Implementierungen und Strategien um in unterschiedlichen Szenarien optimale Ergebnisse zu liefern.

Ein Nachteil der erwähnten Lösungen ist ihre Komplexität im Betrieb. Daher empfiehlt es sich immer eine Software-as-a-Service Lösung, wie zum Beispiel AWS Elasticache oder Google Memorystore zu benutzen.

Caching für Serverless Apps

Für unseren Fall kommt kein lokaler Cache in Frage, da unsere Applikation eine Lambda-Funktion ist und somit viele Instanzen gleichzeitig auf den Cache zugreifen können müssen. Unser Anforderungen an den Cache sind ansonsten vergleichsweise einfach:

Wir wollen Daten mit einer Größe von mehreren Megabyte für genau 30 Minuten speichern. Im schlimmsten Fall würden ein Konsument unserer API also 30 Minuten auf ein Update einer Datei warten müssen. Das ist aus fachlicher Sicht aber absolut vertretbar.

Wir haben die Anwendung mit Lambda und DynamoDB bewusst so serviceful wie möglich gestaltet. Diese Strategie wollen wir auch beim Caching beibehalten.

Für AWS Elasticache ist es nötig Serverinstanzen zu starten. Auch bei keiner oder nur geringer Last bleiben diese unverändert und müssen für Lastspitzen überprovisioniert werden. Das wollen wir vermeiden und sind deshalb auf der Suche nach einer anderen Lösung.

Caching mit DynamoDB

Amazon DynamoDB ist ein dokumentenbasierter Key-Value Store. Dadurch ist DynamoDB sehr gut für das Ablegen und effiziente Auslesen von direkt adressierbaren Daten geeignet. Unsere Dateien entsprechen diesem Muster, da wir mit wenig Aufwand einen eindeutigen Key für jede Datei erstellen können.

Ein weiteres Plus für die Nutzung von DynamoDB als Cache ist das TTL-Feature. Damit ist es möglich, Dokumente mit einer Lebensdauer zu versehen, damit diese dann nach Ablauf automatisch gelöscht werden.

DynamoDB wäre ein guter Kandidat für unseren Anwendungsfall, wenn die zu speichernden Dateien nicht so groß wären. Die maximale Dokumentengröße in DynamoDB liegt derzeit leider nur bei 400 Kilobyte.

Caching mit S3

Keine Probleme mit großen Dateien hat Amazon S3. S3 ist ein Object Storage und bietet neben hoher Verfügbarkeit auch einige Features, die diesen Service besonders gut als Caching Backend nutzbar machen.

S3 bietet zwar nicht die Performance einer In-Memory Database wie z.B. Redis, ist dafür aber einfach als Service nutzbar und erfordert keinen zusätzlichen Wartungsaufwand wie zum Beispiel Skalierung bei Lastspitzen.

Mit S3 Lifecycle Policies lassen sich Objekte nach einer gewissen Zeit aus dem Storage löschen. Dieses Feature eignet sich aber nur für sehr langlebige Caches, da die kleinste Zeiteinheit ein Tag ist.

Besser ist hier die Möglichkeit, beim Abholen eines Objektes zu prüfen, wann es zuletzt geändert wurde. Dafür setzt man den HTTP-Header If-Modified-Since und bekommt nur dann das Objekt zurück, wenn es sich in dem angegebenen Zeitraum verändert hat.

Damit lässt sich eine sehr einfache Caching-Strategie umsetzen:

  • Es wird die Dauer festgelegt, für die Objekte aus dem Cache verwendet werden sollen. In unserem Fall sind das 30 Minuten.
  • Es wird die Zeit modified-since berechnet: now() - 30 Minuten.
  • Es wird versucht das Objekt mit dem GetObject API Call abzuholen. Dabei wird der HTTP-Request-Header If-Modified-Since auf modified-since gesetzt.
  • Kann das Objekt geladen werden, kann es so einfach zurückgegeben werden.
  • Wenn das Objekt nicht geladen werden kann, weil die modified-since-Bedingung nicht erfüllt ist, liefert S3 den HTTP-Status Code 304 - Not Modified
  • Das Objekt muss jetzt aus der Quelle geladen werden. In unserem Fall ist das ein HTTP-GET auf die Datei URL.
  • War das erfolgreich, wird das Objekt mittels S3 PutObject in dem Cache gelegt und zurück an den Client gegeben.

Kombiniert man diese Caching-Strategie mit einer S3 Lifecycle Policy, um die Objekte nach einer gewissen Zeit zu löschen, hat man eine solide und kostengünstige Lösung um auch größere Dateien für variable Zeiträume zu cachen.

Beispielimplementierung

Zur besseren Veranschaulichung haben wir auf unserer Github-Seite eine Beispielimplementierung in Go erstellt. Diese zeigt einen möglichen Weg, wie Caching mit S3 in einem Serviceful-Kontext umgesetzt werden kann. Mit dem entsprechenden AWS SDK ist eine Implementierung in jeder anderen gängigen Programmiersprache aber auch ohne großen Aufwand möglich.

Zusammenfassung

S3 ist vielleicht nicht der naheliegendste Service, wenn es darum geht, Caching für eine Serverless-Applikation zu realisieren. Es gibt aber Szenarien, in denen es sinnvoll ist, etwas über den Tellerrand zu schauen und alternative Lösungen zu erwägen. Mit S3 bietet Amazon einen soliden und kostengünstigen Service an, um Objekte beliebiger Größe zu speichern. Features wie Lifecycle Policy und If-Modified-Since Request Header ermöglichen es, eine einfache Caching-Lösung zu implementieren.