Azure Functions – Die Skalierung begrenzen

3. April 2019

image244Function Apps mit Consumption Plan werden bei Bedarf automatisch skaliert. Das kann jedoch auch zum Problem werden.

Die Skalierung von Functions innerhalb einer Instanz der Function App ist abhängig vom jeweiligen Trigger. Zum Beispiel werden vom HTTP-Trigger 100 Requests parallel abgearbeitet, beim Queue-Trigger sind es 16 Nachrichten; dies sind die Standardwerte, die noch nach oben angepasst werden können. Hinzu kommt, dass bei einem Consumption Plan bei Bedarf neue Instanzen hinzukommen, wie im letzten Beitrag “Azure Functions –  Der App Service Plan” beschrieben.

 

Greifen wir jetzt nochmal das Beispiel aus dem letzten Beitrag auf:

Aus SAP sollen regelmäßige Daten in das eigene System übernommen werden. Diese müssen von SAP abgerufen, nachverarbeitet und in das eigene System eingespielt werden.

Und unterstellen wir jetzt, dass dieser Import für Azure Functions optimiert und in viele kleine Teilimporte zerlegt wurde, die über Queues asynchron angesteuert werden. Die Teilimporte benötigen immer noch eine gewisse Zeit, aber sie laufen schnell genug ab, um nicht Opfer der Laufzeitbeschränkung einer Function im Consumption Plan zu werden.

Daraus könnte sich zur Laufzeit folgendes Szenario ergeben:

image

Viele Instanzen von Functions, jede importiert ihren Teilausschnitt der Daten – und gemeinsam sorgen sie dafür, dass sowohl SAP als auch die Datenbank mit Zugriffen überschwemmt werden. Im konkreten Projekt waren die SAP-Administratoren nicht glücklich mit der Situation.

Allgemeiner formuliert: Die Skalierbarkeit von Azure Functions wird zum Problem, wenn die Skalierbarkeit der Ressourcen, auf die man zugreift, beschränkt ist.

In solchen Fällen möchte man die Skalierung der aufrufenden Functions ebenfalls beschränken – typischerweise als Throttling bezeichnet. Um es kurz zu machen: Azure Functions bietet hierzu keine schlüsselfertige Lösung an.

Es gibt aber trotzdem einige Ansätze, mit denen man spielen kann.

Throttling der Function App Instanzen

Die Anzahl der Function App Instanzen lässt sich auf zwei Arten limitieren: Zum einen mit der Wahl eines herkömmlichen App Service Plans anstelle des Consumption Plans. Damit lässt sich die Zahl der Instanzen genau eingrenzen:image

Das geht allerdings einher mit höheren Kosten.

Falls man beim Consumption Plan bleiben will, hat man die Möglichkeit, die Einstellung WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT zu setzen und so die Skalierung zu begrenzen:

image

Diese Einstellung ist allerdings etwas obskur:

“Set a maximum number of instances that a function app can scale to. This limit is not yet fully supported – it does work to limit your scale out, but there are some cases where it might not be completely foolproof. We’re working on improving this.

In beiden Fällen gilt, dass dies global für die gesamte Function App gilt. Wenn nur einzelne Funktionen begrenzt werden sollen, muss man diese in eigene Function Apps auslagern.

Throttling innerhalb einer Function App

Im Gegensatz zur Begrenzung der Function App Instanzen ist die Möglichkeit der Skalierung innerhalb einer Function App Instanz klar und eindeutig dokumentiert: Der Trigger entscheidet über die Skalierung. Für HTTP-Trigger und Queue-Trigger lassen sich entsprechende Konfigurationseinträge vornehmen, die natürlich auch nach unten korrigiert werden können:

image

Bei HTTP- und Queue-Triggern ist allerdings zu beachten, dass diese Werte global für alle Functions mit entsprechenden Triggern gelten. Man hat keine Möglichkeit, gezielt einzelne Functions mit entsprechenden Triggern zu begrenzen. Und bei mehreren Functions mit Queue-Trigger gibt es auch keine Aussage darüber, welche Functions zuerst zum Zuge kommen.

Beispiel: Habe ich 2 Queues mit jeweils mehreren Nachrichten, dann könnte es durchaus sein, dass erst die gesamte Queue 1 abgearbeitet wird, bevor Queue 2 zum Zuge kommt. Je nach Anwendungsfall kann sich das unterschiedlich gravierend auswirken.

Ein Lösungsansatz für dieses Problem ist wieder die Verlagerung der betroffenen Functions in jeweils eigene Function Apps.

Übrigens: Ein Timer–Trigger ist unabhängig von den Function App Instanzen auf eine einzige Function Instance je Function festgelegt. Hier stellt sich das Problem also erst gar nicht.

Restrukturierung

Natürlich kann man immer versuchen, mehrere Teilaufgaben, die jeweils durch eine Function abgearbeitet werden könnten, in einer Function zusammenzufassen. Bei länger andauernden Verarbeitungen muss man dann aber wieder auf die Laufzeit der Function achten. Es gibt aber Ansätze, um dieses Problem zu umgehen.

Hier einige Beispiele, wieder bezogen auf das Thema “Import aus SAP”:

  • Über Timer: Ein Timer-Trigger nimmt den nächsten Teilimport (etwa aus einer Queue) und verarbeitet diesen. Da von einer Function mit Timer-Trigger nur eine Instanz existiert, hat man die Skalierung auf 1 begrenzt.
  • Nochmal über Timer: Ein Timer-Trigger nimmt den nächsten Teilimport und schreibt ihn in eine weitere Queue, an der die eigentliche Function zur Verarbeitung hängt. Dadurch werden die Teilimporte zeitlich verteilt; abhängig von der Timer-Frequenz und der Dauer der Teilimporte ist auch eine parallele Verarbeitung der Teilimporte möglich.
  • Über Queues: Man schreibt eine Liste von Aufträgen für Teilimporte als eine Nachricht in eine Queue (zum Beispiel als JSON-serialisiertes Array) mit zugeordneter Function. Die Function arbeitet den ersten Eintrag der Liste ab und schreibt am Ende die um diesen Eintrag verkürzte Liste wieder in die Queue. Somit wird die Liste sequentiell abgearbeitet, die Laufzeit der einzelnen Functions verlängert sich dabei aber nicht. Diese Lösung erlaubt beim initialen Aufteilen der Teilimporte in Teillisten eine genaue Steuerung der parallelen Verarbeitung.
  • Durable Functions: Mit Hilfe von Durable Functions (ab Azure Functions v2) kann man Aufrufe nachgelagerter Functions feingranular steuern. Man kann eine bestimmte Anzahl von Teilimporten “starten”; immer wenn einer dieser Teilimporte fertig ist wird der nächste Teilimport gestartet. Das geschieht solange, bis alle Teilimporte abgearbeitet sind. Der Vorteil ist hier, dass man bis zum Ende hin die parallele Verarbeitung aufrecht erhält, unabhängig von der Dauer der Einzelimporte. Als kleine Warnung: Zu Durable Functions gibt es bislang wenig Erfahrungswerte und ich hatte bei Tests einige seltsame Effekte. Wie robust diese Lösung ist und welches Zeitverhalten sie zeigt müsste also geprüft werden.

Diese Beispiele sollten genügend Denkanstöße liefern, um im konkreten Einzelfall eine passende Lösung zu finden.

Eigene Lösung

Die bisher vorgestellten Möglichkeiten haben auf Lösungen abgezielt, die sich mit den “Bord-Mitteln” von Azure Functions direkt umsetzen lassen. Ist das nicht ausreichend, dann kann man immer noch seine eigene Steuerung implementieren.

Verwaltet man die Liste der Teilimporte in der Datenbank, dann kann man auch die Information zum Verarbeitungsstatus dazulegen. Darüber lässt sich dann leicht steuern, wie viele Teilimporte parallel abgearbeitet werden.

Oder man geht einen Schritt weiter und baut sich seine eigene kleine Job-Steuerung auf. Tatsächlich ist das kein Hexenwerk und wurde im konkreten Projekt auch bereits prototypisch umgesetzt. Diese Umsetzung basiert auf etwas Logik, einer Status-Tabelle in der Datenbank und einer kleinen Anzahl von… Azure Functions!

Fazit

Azure Function Apps tun alles, um Skalierbarkeit sicherzustellen. Wenn aber genau diese Skalierbarkeit zum Problem wird, dann ist es relativ umständlich, sich dagegen zu wehren.

Oder um es deutlich zu sagen: Aus Sicht meiner Anforderungen betrachtet – ich möchte die maximale Anzahl parallel ausgeführter Instanzen einzelner Functions beschränken – sind die Möglichkeiten von Azure Functions schlicht unzureichend.

Die konfigurativen Möglichkeiten stellen bestenfalls schnelle Workarounds dar – mehr Hack als Lösung. Ansätze über verschiedene Trigger funktionieren zwar, sorgen aber auch für erhöhte Komplexität meiner Anwendung. Eine eigene Job-Steuerung muss zunächst einmal robust und wiederverwendbar implementiert werden.

Letztendlich kommt man also nicht darum herum, die eigenen Anforderungen genau zu prüfen und selbst zu entscheiden, wie die passende Lösung aussehen soll.