Noel Lang - Ein Blog über Softwareentwicklung, Java und Spring.

Spring Boot als Monolith auf AWS Lambda bereitstellen

15 min Lesezeit

Als Entwickler möchte ich vor allem eine Sache machen: Entwickeln. (Duh)

Wenn ich ehrlich bin, möchte ich mich nicht mit Serverinfrastruktur auseinandersetzen, keine Updates einspielen – ich will einfach nur, dass alles reibungslos läuft. Deswegen ist für mich der Gedanke hinter “Serverless” absolut verlockend: Mein Cloud-Anbieter kümmert sich um alles, ich werfe ihm einfach nur meine Anwendung zu und tadaa: alle sind zufrieden.

Allerdings werde ich mit dem Gedanken nicht warm, all meine Anwendungen in einzelne Funktionen aufzuteilen, um die ich mich dann kümmern muss. Klar, das ist ein absolut berechtigter Ansatz, aber nicht für mich als einzelner Entwickler. Deshalb habe ich mich auf die Suche nach einer Alternative gemacht, die meinen Vorlieben besser entspricht.

Letztens wollte ich mich informieren, wie ich eine Spring Boot 3 Anwendung mit Java 17 auf AWS Lambda betreiben kann und bin dann immer weiter ins Rabbit Hole eingestiegen, bis ich schließlich meinen eigenen Proof of Concept auf die Beine gestellt habe, um eine komplette Spring Boot Anwendung als Monolith innerhalb einer einzelnen AWS Lambda Funktion laufen zu lassen. Alleine wegen der Boot Time von Spring Boot werden jetzt einige sagen: was?? Bist du verrückt?? Aber lasst es mich erklären – Schritt für Schritt, in diesem Blogartikel.

Wer die TL;DR-Version haben möchte, kann sich das dazugehörige GitHub Repository anschauen. Und für alle anderen, die neugierig sind und mehr erfahren möchten, legen wir jetzt los!

Serverless Computing und AWS Lambda

Unter dem Konzept “Serverless” stellt dir der Cloud-Anbieter deiner Wahl die Infrastruktur für deine Anwendung zur Verfügung, ohne dass du dich selbst um diese kümmern musst. Dir wird die Verantwortung für Bereitstellung, Skalierung und Verwaltung der Server abgenommen. Du musst lediglich deine Anwendung mitbringen. Das Shared Responsibility Modell von AWS beschreibt dieses Konzept ganz gut: Du, als Entwickler, kümmerst dich deine Funktionen und Code, die Konfiguration deiner Ressourcen sowie um Identity & Access Management. AWS übernimmt die Bereitstellung der Netzwerkinfrastruktur, der Laufzeitumgebung, der Server Soft- und Hardware sowie den einzelnen Verfügbarkeitszonen und alle sonstigen Komponenten, die für die Bereitstellung eine Rolle spielen.

AWS Lambda im Speziellen ist ein eventbasierter, serverloser Dienst von Amazon Web Services. Lambda basiert auf dem Konzept von Events, auf deren Grundlage dann eine Funktion ausgeführt wird. Das kann ein HTTP Request über ein Amazon API Gateway sein, eine Modifizierung eines Objekts in S3, eine Message im Simple Notification Service oder ein Protolleintrag in CloudWatch. Es geht hier also nicht unbedingt nur um Webanwendungen, sondern um eine eventbasierte Infrastruktur, die sich auf flexible Art und Weise auslösen lässt.

Dadurch ergeben sich viele Vorteile, für mich spielen vor allem folgende Punkte eine Rolle:

Jetzt geht es bei Function as a Service Angeboten darum, einzelne, in sich abgeschlossene Funktionalitäten zu verwalten und keine kompletten Anwendungen, da wir so natürlich nicht unsere Funktionen je nach Last skalieren können. Aber warum sollte ich diese Vorteile nicht auch für eine komplette Anwendung nutzen können? Laravel macht es mit Laravel Vapor beispielhaft vor, indem sie eine komplette Laravel Anwendung auf der AWS Lambda ausführen können. Zugegebenermaßen ist PHP damit auch wie für gemacht, da im Endeffekt jedes mal die index.php von Beginn auf aufgerufen wird und wir keine Spring Boot Instanz hochfahren müssen, die erstmal 30 Sekunden braucht, um einsatzbereit zu sein.

Aber es gibt Mittel und Wege, eine Spring Boot Anwendung als Monolith innerhalb einer AWS Lambda Funktion zu betreiben. Ich möchte nämlich die Vorteile der Skalierung und Abrechnung nutzen, wenn ich für mich als Solo-Entwickler Projekte umsetze, bei welchen ich keine großen Aufwände für den Betrieb der Infrastruktur haben möchte und gleichzeitig preislich sehr gut unterwegs bin. Klar, kann ich meine Anwendungen in einem Managed Service laufen lassen, aber danach bin ich halt pleite.

Kommen wir zur Sache: Wie wir das implementieren

Die Grundlage bildet ein normales Spring Boot Projekt, welches ich mit dem Spring Initializr erstellt habe. Als einzige Dependency benötigen wir Spring Web, damit wir einen Endpunkt erstellen können. Um die Anwendung zu testen definiere ich zwei simple Endpunkte, die mir jeweils nur einen String zurückgeben:

@SpringBootApplication
@RestController
public class SpringBootAwsLambdaApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringBootAwsLambdaApplication.class, args);
	}

	@GetMapping("/hello-world")
	public ResponseEntity<String> helloWorld() {
		return ResponseEntity.ok("Hello World 123! Heheeeee");
	}

	@GetMapping("/")
	public String standard() {
		return "Das ist ein anderer Default-Wert 123! 456 trololo";
	}

}

Dockerfile vorbereiten

Mehr brauchen wir für unseren Proof of Concept auch gar nicht. Jetzt wird es allerdings spannend: wir brauchen ein Docker Image, welches unsere Anwendung enthält, welches wir dann schlussendlich auf AWS Lambda laufen lassen können. Im Rahmen dieser Anwendung will ich das AWS Serverless Application Model verwenden, was uns die Erstellung der benötigten Ressourcen (Rollen, API Gateway, Lambda) abnimmt und wir nur unser Image bereitstellen müssen.

Achtung: Als ich diesen Blogpost angefangen habe zu schreiben, gab es noch kein fertiges Image für SAM Anwendungen mit Java 17. Der Merge Request war offen, allerdings noch nicht gemerged. Mittlerweile gibt es das Image, welches ihr hier finden könnt. In diesem Tutorial bauen wir uns das von Hand. Auf diese ganzen Befehle bin ich natürlich nicht selbst gekommen, sondern diese stammen aus dem verlinkten Merge Request, lediglich der anwendungsspezifische Teil weiter unten habe ich ergänzt, aber das ist auch keine Meisterleistung. Aber egal, wir wuseln uns mal durch!

In unserem Dockerfile setzen wir also auf das vorhandene Basisimage public.ecr.aws/lambda/java:17-x86_64 auf, um von dort aus ein SAM Image nachzubilden. Dafür müssen wir erstmal verschiedenste Pakete installieren:

RUN yum groupinstall -y development && \
  yum install -d1 -y \
  yum \
  tar \
  gzip \
  unzip \
  python3 \
  jq \
  grep \
  curl \
  make \
  rsync \
  binutils \
  gcc-c++ \
  procps \
  libgmp3-dev \
  zlib1g-dev \
  libmpc-devel \
  python3-devel \
  && yum clean all

Darauf folgt die AWS CLI und die SAM CLI:

ARG AWS_CLI_ARCH
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-$AWS_CLI_ARCH.zip" -o "awscliv2.zip" && \
    unzip awscliv2.zip && \
    ./aws/install && \
    rm awscliv2.zip && \
    rm -rf ./aws

ARG SAM_CLI_VERSION
RUN curl -L "https://github.com/awslabs/aws-sam-cli/archive/v$SAM_CLI_VERSION.zip" -o "samcli.zip" && \
  unzip samcli.zip && python3 -m venv /usr/local/opt/sam-cli && \
  /usr/local/opt/sam-cli/bin/pip3 --no-cache-dir install -r ./aws-sam-cli-$SAM_CLI_VERSION/requirements/base.txt && \
  /usr/local/opt/sam-cli/bin/pip3 --no-cache-dir install ./aws-sam-cli-$SAM_CLI_VERSION && \
  rm samcli.zip && rm -rf aws-sam-cli-$SAM_CLI_VERSION

Und natürlich darf Gradle nicht fehlen:

RUN mkdir /usr/local/gradle && curl -L -o gradle.zip https://downloads.gradle-dn.com/distributions/gradle-7.3.1-bin.zip && \
  unzip -d /usr/local/gradle gradle.zip && rm gradle.zip && mkdir /usr/local/maven && \
  curl -L https://downloads.apache.org/maven/maven-3/3.8.8/binaries/apache-maven-3.8.8-bin.tar.gz | \
  tar -zx -C /usr/local/maven

Das genaue Dockerfile könnt ihr hier einsehen, ein paar wenige Zwischenschritte wie Umgebungsvariablen oder Arbeitspfade setzen lasse ich hier raus, gehören aber der Vollständigkeit halber dazu.

Jetzt kommt ein wichtiger Punkt, der den Monolithen auf AWS Lambda überhaupt möglich macht: Der AWS Lambda Adapter.

COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.0 /lambda-adapter /opt/extensions/lambda-adapter

Dieses Projekt ermöglicht es uns, Webanwendungen jeder Art (Next.js, Flask, Laravel, etc..) auf AWS Lambda laufen zu lassen. Der AWS Lambda Adapter funktioniert als Lambda-Erweiterung und kümmert sich darum, dass die eingehenden Events für die Lambda-Funktion verständlich gemacht und zurückgegeben werden.

Zum Schluss kopieren wir unsere Java-Anwendung rein und führen die JAR-Datei aus:

COPY build/libs/spring-boot-aws-lambda-0.0.1-SNAPSHOT.jar app.jar

CMD ["java", "-jar", "app.jar"]

Jetzt können wir das Docker Image einmal bauen und es lokal ausprobieren. Davor müssen wir uns allerdings erst mit der AWS CLI gegenüber dem öffentlichen ECR Repositories authentifizieren. Das machen wir folgendermaßen:

aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws

Wenn alles geklappt hat, seht ihr die Meldung “Login Succeeded”. Jetzt bauen wir das Image:

docker build -t spring-boot-test:latest . --build-arg SAM_CLI_VERSION=1.80.0 --build-arg AWS_CLI_ARCH=aarch64

Und starten es:

docker run -p 8080:8080 --name spring-boot-testinstanz -d spring-boot-test:latest

Wenn alles bis hierhin funktioniert, bekommt ihr unter localhost:8080 genau das zurück, was wir unter dem Default Endpunkt definiert haben.

img.png

Docker Image in Amazon ECR bereitstellen

Die Amazon Elastic Container Registry ist ein von AWS verwalteter Dienst zum Speichern und Verwalten von Docker Images. Wir müssen unser Image dort ablegen, damit wir es für die Lambda Funktion zur Verfügung stellen können. Lasst uns das Stück für Stück durchgehen:

1. Erstelle ein privates Repository in der AWS-Konsole

Zuerst musst du ein privates Image-Repository innerhalb der AWS-Konsole anlegen. Das sieht dann ungefähr so aus:

img.png

2. Authentifiziere dich bei der ECR-Privatinstanz

Gebe den folgenden Befehl ein, um dich mit deiner AWS Account-ID bei der ECR-Privatinstanz zu authentifizieren:

aws ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin <account-id>.dkr.ecr.eu-central-1.amazonaws.com

3. Erstelle das Docker-Image

Jetzt kannst du den folgenden Befehl anpassen, um das Image zu bauen. Hier müssen wir als Tag die URL angeben, unter der das ECR-Repository erreichbar ist sowie das Tag, unter welchem wir das Image führen wollen:

docker build -t <account-id>.dkr.ecr.eu-central-1.amazonaws.com/<repository-name>:latest . --build-arg SAM_CLI_VERSION=1.80.0 --build-arg AWS_CLI_ARCH=aarch64

Falls du dich fragst, was die Build-Argumente bedeuten: SAM_CLI_VERSION gibt die Version der AWS Serverless Application Model (SAM) CLI an, die wir verwenden möchten, und AWS_CLI_ARCH definiert die Architektur für die AWS CLI.

4. Lade das Image in ECR hoch

Zum Schluss kannst du den docker push Befehl verwenden, um das getaggte Image hochzuladen:

docker push <account-id>.dkr.ecr.eu-central-1.amazonaws.com/<repository-name>:latest 

Mit AWS SAM das Deployment aufsetzen

Nachdem wir uns nun um die Bereitstellung des Docker Images gekümmert haben, können wir nun mit der Infrastruktur der eigentlichen Anwendung beginnen. Hierfür verwenden wir das Serverless Application Model. Hierbei handelt es sich um ein Open Source Framework, was das Entwickeln, das Testen und das Deployment von serverlosen Anwendungen auf AWS vereinfacht. SAM ist eine Erweiterung von AWS CloudFormation und kann über YAML- oder JSON-Templates definiert werden. Wir müssen uns nicht mehr um die Provisionierung von Ressourcen kümmern, über Updates, Änderungen oder Löschungen, sondern könenn einfach unseren Ist-Zustand der gewünschten Architektur definieren und das Tooling kümmert sich um den Rest.

Für unseren Fall kann unsere template.yaml Konfigurationsdatei folgendermaßen aussehen:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Spring Boot Monolith powered by AWS Lambda
Globals:
  Function:
    Timeout: 10

Resources:
  SpringBootAwsLambdaFunction:
    Type: AWS::Serverless::Function
    Properties:
      PackageType: Image
      ImageUri: <account-id>.dkr.ecr.eu-central-1.amazonaws.com/spring-boot-test:latest
      MemorySize: 1024
      Tracing: Active
      Environment:
        Variables:
          REMOVE_BASE_PATH: /v1
      AutoPublishAlias: live
      DeploymentPreference:
        Type: AllAtOnce
      Events:
        Root:
          Type: HttpApi
          Properties:
            Path: /v1
            Method: ANY
        SpringBootAwsLambda:
          Type: HttpApi
          Properties:
            Path: /v1/{proxy+}
            Method: ANY
    Metadata:
      Dockerfile: Dockerfile
Outputs:
  SpringBootAwsLambdaApi:
    Description: "API Gateway Endpoint URL"
    Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/"
  1. AWSTemplateFormatVersion und Transform: Diese beiden Zeilen definieren die Version des AWS CloudFormation-Templates und die spezifische SAM-Transformation, die angewendet werden soll.

  2. Description: Eine kurze Beschreibung der Anwendung.

  3. Globals: Der Globals-Bereich definiert Einstellungen, die für alle Funktionen innerhalb des Templates gelten. Hier wird der Timeout für Lambda-Funktionen auf 10 Sekunden festgelegt.

  4. Resources: Hier werden alle AWS-Ressourcen für die Anwendung definiert.

    a. SpringBootAwsLambdaFunction: Diese Ressource beschreibt eine Lambda-Funktion.

  1. Metadata: Zusätzliche Informationen zum Template, wie zum Beispiel der Name der verwendeten Dockerfile.

Nun können wir uns eine samconfig.toml erstellen, um die Ausführung der Befehle sam package und sam build zu vereinfachen:

version = 0.1
[default]
[default.deploy]
[default.deploy.parameters]
stack_name = "spring-boot-on-lambda"
s3_bucket = "spring-boot-test-s3"
capabilities = "CAPABILITY_IAM"
template_file = ".aws-sam/build/template.yaml"
image_repository = "<account-id>.dkr.ecr.eu-central-1.amazonaws.com/<repository-name>"
region = "eu-central-1"

[default.build]
[default.build.parameters]
template = "template.yaml"
use_container = true
  1. version: Gibt die Version der samconfig.toml-Datei an.

  2. [default]: Definiert die Standardeinstellungen für die SAM-Kommandos.

  3. [default.deploy] und [default.deploy.parameters]: Enthält die Parameter für das sam deploy-Kommando.

  1. [default.build] und [default.build.parameters]: Enthält die Parameter für das sam build-Kommando.

sam build erstellt Anwendungsartefakte und lädt Abhängigkeiten für die in der SAM-Template-Datei definierten Ressourcen herunter.

sam deploy verwendet die erstellten Artefakte und die SAM-Template-Datei, um die Anwendung in der AWS-Umgebung bereitzustellen. Zusammen vereinfachen sie den Lebenszyklus einer serverlosen Anwendung von Entwicklung bis Bereitstellung.

Wenn ihr diesen Befehl eingebt, bekommt ihr folgende Übersicht:

007.png

Und hier kommt die Magic: CloudFormation kümmert sich um die Erstellung aller Notwendigen Ressourcen:

img.png

Sobald das Deployment erfolgreich durchgeführt wurde, bekommen wir die abrufbare URL des API Gateways angezeigt:

img.png

Nicht vergessen, hier noch den “/v1”-Pfad zu ergänzen, da wir in unserem Template definiert haben, dass das API Gateway alle Requests unter diesem Pfad zur Anwendung weiterleitet.

img.png

Geschafft! Unsere Anwendung läuft innerhalb einer einzelnen AWS Lambda Funktion, gäbe es da nur nicht ein winziges Problem…

Nur ein warmes Lambda ist ein gutes Lambda

Ich führe nun den ersten GET-Request gegen meine neue Anwendung aus und wundere mich erstmal, warum ich so lange warten muss, um eine Antwort zu bekommen. Genauer gesagt dauert es 9 Sekunden, bis ich meine erwartete Antwort erhalte.

img.png

Das liegt aber nicht daran, dass die Anwendung plötzlich langsam und unperformant geworden ist. Die nachfolgenden Requests sind nämlich mit ca. 50ms deutlich schneller:

img.png

Es liegt an der Art und Weise von Lambda selbst. Unsere Funktion ist ja nicht bis in alle Tage verfügbar, das ist ja auch gar nicht der Sinn dahinter. Sie wird aufgerufen, die Laufzeitumgebung wird gestartet und anschließend wird sie wieder heruntergefahren. Das trifft genau den Sinn hinter der eventbasierten Architektur - und da müssen wir jetzt reingrätschen. Unsere Funktion soll dauerhaft laufen - und genau das nennt man “den Lambda warm halten”. Und am einfachsten schafft man das, indem man regelmäßig Requests hinschickt, die der Funktion symbolisieren “hey, du da, ich brauch dich noch!“.

Deswegen können wir durch Amazon EventBridge eine Regel aufstellen, durch welche wir jede 5 Minuten ein Event an unsere Funktion schicken, um diese warm zu halten. So macht das Laravel Vapor auch. Diese Regel definieren wir direkt in unserer template.yaml. Unter “Resources” fügen wir Folgendes hinzu:

  WarmUpFunction:
    Type: AWS::Serverless::Function
    Properties:
      Runtime: nodejs14.x
      Handler: index.handler
      InlineCode: |
        const https = require('https');

        exports.handler = async (event, context) => {
          const apiGatewayUrl = process.env.API_GATEWAY_URL;

          return new Promise((resolve, reject) => {
            https.get(apiGatewayUrl + "/v1", (res) => {
              resolve({
                statusCode: 200,
                body: JSON.stringify("Warm up finished")
              });
            }).on('error', (err) => {
              console.log(`Error: ${err.message}`);
              reject({
                statusCode: 500,
                body: JSON.stringify("Warm up failed")
              });
            });
          });
        };
      Environment:
        Variables:
          API_GATEWAY_URL: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/"
  KeepLambdaWarmRule:
    Type: AWS::Events::Rule
    Properties:
      Description: "Schedules a warm-up event every 5 minutes."
      ScheduleExpression: "rate(5 minutes)"
      State: "ENABLED"
      Targets:
        - Arn: !GetAtt WarmUpFunction.Arn
          Id: WarmUpTarget
  WarmUpFunctionRoleLambda:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: WarmUpFunctionLogs
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: '*'
        - PolicyName: WarmUpFunctionInternetAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ec2:CreateNetworkInterface
                  - ec2:DescribeNetworkInterfaces
                  - ec2:DeleteNetworkInterface
                Resource: '*'
  WarmUpFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt WarmUpFunction.Arn
      Principal: events.amazonaws.com
      SourceArn: !GetAtt KeepLambdaWarmRule.Arn

Gehen wir mal Schritt für Schritt durch. Zuerst erstellen wir eine weitere Lambda-Funktion, die unser eigentliches Lambda “warm” halten soll. Damit meine ich, dass in einem regelmäßigen Abstand HTTP Requests an unser Spring Boot Backend geschickt werden, damit die Laufzeitumgebung nicht heruntergefahren wird. Das habe ich mit einer minimalen node.js Anwendung umgesetzt, die sich sogar innerhalb der Konfigurationsdatei beschreiben lässt. Anschließend erstelle ich eine EventBridge-Regel, die dieses Lambda alle 5 Minuten aufrufen soll, die Referenz erhält die Regel über den ARN, kurz für “Amazon Resource Name”. Dafür benötige ich noch eine IAM-Rolle, die dem Lambda die nötigen Berechtigungen gewährt sowie eine Permission für die Regel, damit diese die Lambda-Funktion auslösen darf.

Durch die CloudWatch Logs kann ich dann nachvollziehen, dass unsere Warmup-Funktion alle 5 Minuten ausgelöst wird.

img.png

Dadurch ist unsere Anwendung nun immer einsatzbereit ohne, dass wir große Latenzen in Kauf nehmen müssen.

Provisioned Concurrency

Alternativ können wir die ProvisionedConcurrency einsetzen, die uns automatisch weitere Lambda Instanzen bereithält. Das wird allerdings schnell ziemlich teuer und ist somit für mich keine passende Alternative. In einem Projekt in der echten Welt, wo es jetzt nicht auf 3$ pro Tag ankommt, wäre das natürlich nochmal etwas anderes.

img.png

Sollte man das so für ein echtes Projekt machen? Vielleicht. Gibt es dafür bessere Sprachen und Frameworks als Java? Definitiv. Ich finde, man sollte sich nicht zu sehr auf ein Architekturmuster versteifen und sich gerne mal Konzepte aus anderen Sprachen und Ökosystemen leihen. Dennoch habe ich dieses Konzept in Bezug auf Java viel zu wenig ausprobiert, als dass ich für komplexere Projekte dafür eine Aussage treffen könnte. Ich werde definitiv versuchen, meine zukünftigen Projekte über diese Infrastruktur zu hosten und mal einige komplexere Versuche zu wagen, aber in einem größeren Projekt (mit mehr Budget als ich für meine Hobbyprojekte, lol) wäre etwas wie Amazon ECS oder Fargate, vielleicht auch AWS App Runner, viel mehr dafür geeignet, als mein Hack mit AWS Lambdas. Was meint ihr?

Hier noch einige weiterführende Links, falls du genauso wie ich in dieses Rabbit Hole sinken möchtest: