Performance of running Spring Boot as AWS Lambda functions

I was always curious if there’s any way to use Spring Boot in the serverless world. I started digging deeper into the area and I came to the conclusion that it’s very possible. I guess my question unconsciously was not only if there’s a possibility to use Spring but whether it’s worth to use it,

In this article I’m going to go into how much extra latency does using Spring Boot add into the overall execution of a minimalistic function. For the tests I’m gonna be using AWS Lambda with a Java runtime.

I think deciding if Spring Boot is worth using is not black or white. You always have to put measurement results into perspective. For reference, I’m going to use a plain Java lambda function. And for the actual test, I’ll create a Spring Boot version too, both fulfilling the same functionality.

Architecture

I’ll keep the architecture very simple. There’s gonna be a lambda function with Java 11 runtime serving the API, and it’s going to be exposed via an HTTP AWS API Gateway. For the sake of the test, I’ll have a single API, /books.

With this architecture in mind, we can easily measure how much latency gets added when Spring Boot is used for a serverless function because we don’t have any other intervening factors like databases, external services, and so on.

Plain Java function

I’ll jump right into coding. The Java function is going to be the simplest ever. First of all we’ll need the domain class, Book.

public class Book {
    private int id;
    private String name;

    public Book(int id, String name) {
        this.id = id;
        this.name = name;
    }

    // getters & setters omitted
}

Then, I’ll have a wrapper for the API Gateway proxy integration response, according to the docs.

public class ApiGatewayProxyResponse {
    private int statusCode;
    private Map<String, String> headers;
    private String body;

    public ApiGatewayProxyResponse(int statusCode, Map<String, String> headers, String body) {
        this.statusCode = statusCode;
        this.headers = headers;
        this.body = body;
    }

    // getters & setters omitted
}

And the last piece is to create a handler that AWS will invoke upon an API request.

public class LambdaHandler implements RequestHandler<Map<String, Object>, ApiGatewayProxyResponse> {
    private static Gson gson = new Gson();

    private Map<Integer, Book> bookMap = new HashMap<>();

    public LambdaHandler() {
        bookMap.put(1, new Book(1, "Effective Java"));
        bookMap.put(2, new Book(2, "Running Spring in Serverless"));
    }

    @Override
    public ApiGatewayProxyResponse handleRequest(Map<String, Object> input, Context context) {
        return new ApiGatewayProxyResponse(200, null, gson.toJson(bookMap.values()));
    }
}

There are 2 things happening in the handler’s code. One is, we’re creating a map of Books that will be returned in the response. And in the handleRequest method, the ApiGatewayProxyResponse is being returned with HTTP 200 and the respective Books.

Spring Boot function

Now on the other hand, let’s look at the code for the Spring Boot lambda function.

On top of the Book domain object seen previously, I’ll have the usual main class for a Spring application:

@Configuration
@EnableAutoConfiguration
@Import({BookController.class})
public class AwsLambdaSpringBoot2Application {
	public static void main(String[] args) {
		SpringApplication.run(AwsLambdaSpringBoot2Application.class, args);
	}
}

The only change I’ve made is replacing the @SpringBootApplication annotation with the @Configuration and @EnableAutoConfiguration annotations to avoid automatic component scanning. This can speed up the startup time of the lambda function especially if you have a lot of components. Instead of the scan, I’ve used the @Import annotation to specifically tell Spring which component I want it to load.

Since this is going to serve a REST API, I’ll have a controller class that maps to the /books API.

@RestController
public class BookController implements InitializingBean {
    private Map<Integer, Book> bookMap = new HashMap<>();

    @Override
    public void afterPropertiesSet() throws Exception {
        bookMap.put(1, new Book(1, "Effective Java"));
        bookMap.put(2, new Book(2, "Running Spring in Serverless"));
    }

    @RequestMapping("/books")
    public Collection<Book> getBooks() {
        return bookMap.values();
    }
}

Nothing special, same setup as for the plain Java version with the addition of the Spring annotations.

And here’s the magic, to wire this all together into an API Gateway Lambda integration. AWS was kind enough to prepare a package that does exactly that for Spring Boot 2 called aws-serverless-java-container-springboot2.

I’m importing it to the project – I’m using Gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.amazonaws.serverless:aws-serverless-java-container-springboot2:1.5.2'
    implementation 'io.symphonia:lambda-logging:1.0.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

If we’re already at the Gradle build, let’s just get rid of the embedded tomcat Spring Boot is shipped with to reduce the size of the JAR we’re going to produce:

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from(configurations.compileClasspath) {
            exclude 'tomcat-embed-*'
        }
    }
}

build.dependsOn buildZip

And here’s the full Gradle build:

plugins {
    id 'org.springframework.boot' version '2.4.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id "com.github.johnrengelman.shadow" version "6.1.0"
    id 'java'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.amazonaws.serverless:aws-serverless-java-container-springboot2:1.5.2'
    implementation 'io.symphonia:lambda-logging:1.0.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from(configurations.compileClasspath) {
            exclude 'tomcat-embed-*'
        }
    }
}

build.dependsOn buildZip

Now. Going back to coding. We still need a bridge for the lambda invocation to be mapped onto a Spring Boot API request. Using the package AWS has given, let’s create a lambda handler:

public class LambdaHandler implements RequestStreamHandler {
    private static SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;

    static {
        try {
            handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(AwsLambdaSpringBoot2Application.class);
        } catch (ContainerInitializationException e) {
            e.printStackTrace();
            throw new RuntimeException("Could not initialize Spring Boot application", e);
        }
    }

    @Override
    public void handleRequest(InputStream inputStream, OutputStream outputStream, Context context)
            throws IOException {
        handler.proxyStream(inputStream, outputStream, context);
    }
}

The crucial piece we have to use from this library is this: SpringBootLambdaContainerHandler.getAwsProxyHandler(AwsLambdaSpringBoot2Application.class)

This is how you can build up a Spring context in a way that the AWS API Gateway proxy integrations will be transformed into Spring handled API requests.

Also, there’s another trick. Using a static variable for storing the reference to the built-up Spring context. It’s intentional. It’s the solution to cache data between your lambda invocations for the same lambda environment. From a practical standpoint, it means that when the lambda function receives its first invocation, the Spring context will be built-up, and subsequent invocations for that particular lambda instance will use the already built-up context until the lambda environment is killed by AWS.

Deploying

That was the coding part. Let’s look at the deployment. Obviously you can pull this together from the AWS console manually, but I’ll be a little more sophisticated here and get a CloudFormation template with all the necessary resources:

AWSTemplateFormatVersion: 2010-09-09
Description: Using Spring Boot in Lambda example

Parameters:
  apiGatewayStageName:
    Type: String
    Default: dev
  apiGatewayName:
    Type: String
  functionName:
    Type: String
  functionHandler:
    Type: String
  s3BucketKey:
    Type: String


Resources:
  apiGateway:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Description: Example API Gateway
      EndpointConfiguration:
        Types:
          - REGIONAL
      Name: !Ref apiGatewayName

  booksResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref apiGateway
      ParentId: !GetAtt apiGateway.RootResourceId
      PathPart: 'books'
  booksGetMethod:
    Type: AWS::ApiGateway::Method
    Properties:
      AuthorizationType: NONE
      HttpMethod: GET
      Integration:
        IntegrationHttpMethod: POST
        Type: AWS_PROXY
        Uri: !Sub
          - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations
          - lambdaArn: !GetAtt lambdaFunction.Arn
      ResourceId: !Ref booksResource
      RestApiId: !Ref apiGateway

  apiGatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - booksGetMethod
    Properties:
      RestApiId: !Ref apiGateway
      StageName: !Ref apiGatewayStageName

  lambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        S3Bucket: aws-lambda-spring-boot2
        S3Key: !Ref s3BucketKey
      Description: The Lambda function
      FunctionName: !Ref functionName
      Handler: !Ref functionHandler
      MemorySize: 256
      Role: !GetAtt lambdaIAMRole.Arn
      Runtime: java11

  lambdaApiGatewayInvoke:
    Type: AWS::Lambda::Permission
    DependsOn:
      - apiGateway
      - lambdaFunction
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref lambdaFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/*/*/*

  lambdaIAMRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Action:
              - sts:AssumeRole
            Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
      Policies:
        - PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Effect: Allow
                Resource:
                  - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${functionName}:*
          PolicyName: lambda

  lambdaLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${functionName}
      RetentionInDays: 90

Outputs:
  apiGatewayInvokeURL:
    Value: !Sub https://${apiGateway}.execute-api.${AWS::Region}.amazonaws.com/${apiGatewayStageName}

I don’t really want to go into detail on the CloudFormation template because if you’ve worked with API Gateway and Lambda, it’s going to be easy to understand for sure.

For deploying the CloudFormation template, you can also use AWS console and pass over the necessary parameters, but I’ll use AWS CLI for now. I’ll use the same stack with different parameters to deploy the plain Java version of the lambda and the Spring Boot version of the lambda.

Although we’ll need one manual step in the process, creating the S3 bucket where the JARs will be uploaded. I’ve created it via the console and named it aws-lambda-spring-boot2 bucket.

Here’s the deploy script:

# Plain Java stack
aws s3 cp plainjava/build/libs/plainjava-0.0.1-SNAPSHOT-all.jar s3://aws-lambda-spring-boot2/plainjava-0.0.1-SNAPSHOT-all.jar
aws cloudformation deploy --template-file stack.yml --stack-name aws-lambda-plain-java-stack --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
  apiGatewayName=aws-lambda-plainjava-api \
  functionName=aws-lambda-plain-java-function \
  functionHandler=com.arnoldgalovics.blog.LambdaHandler \
  s3BucketKey=plainjava-0.0.1-SNAPSHOT-all.jar
aws cloudformation describe-stacks --stack-name aws-lambda-plain-java-stack --query "Stacks[0].Outputs" --output json

# Spring Boot 2 stack
aws s3 cp springboot2/build/libs/springboot2-0.0.1-SNAPSHOT-all.jar s3://aws-lambda-spring-boot2/springboot2-0.0.1-SNAPSHOT-all.jar
aws cloudformation deploy --template-file stack.yml --stack-name aws-lambda-spring-boot2-stack --capabilities CAPABILITY_NAMED_IAM \
  --parameter-overrides \
  apiGatewayName=aws-lambda-springboot2-api \
  functionName=aws-lambda-spring-boot2-function \
  functionHandler=com.arnoldgalovics.blog.LambdaHandler \
  s3BucketKey=springboot2-0.0.1-SNAPSHOT-all.jar
aws cloudformation describe-stacks --stack-name aws-lambda-spring-boot2-stack --query "Stacks[0].Outputs" --output json

The script will deploy 2 CloudFormation stacks respectively and print out the public API Gateway endpoints that can be invoked, like this:

$ ./deploy.sh
upload: plainjava\build\libs\plainjava-0.0.1-SNAPSHOT-all.jar to s3://aws-lambda-spring-boot2/plainjava-0.0.1-SNAPSHOT-all.jar

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - aws-lambda-plain-java-stack
[
    {
        "OutputKey": "apiGatewayInvokeURL",
        "OutputValue": "https://c7e4hd5w65.execute-api.eu-central-1.amazonaws.com/dev"
    }
]

upload: springboot2\build\libs\springboot2-0.0.1-SNAPSHOT-all.jar to s3://aws-lambda-spring-boot2/springboot2-0.0.1-SNAPSHOT-all.jar

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - aws-lambda-spring-boot2-stack
[
    {
        "OutputKey": "apiGatewayInvokeURL",
        "OutputValue": "https://ufayx845ej.execute-api.eu-central-1.amazonaws.com/dev"
    }
]

Of course make sure to build the lambda function JARs beforehand, I’ve used this command:

$ ./gradlew clean shadowJar

The performance

Now let’s check out how the lambda functions are performing upon invocation. I’ve made several tests to come up with an average latency. Here are the results:

Let me summarize the chart. When the lambda is invoked the first time – cold start – for a relatively small packaged Java function takes around 800ms to respond. When the same happens to a Spring Boot lambda, it takes around 7500ms to respond. This can be scary but as soon as the first startup (and the context initialization) is done for the Spring Boot lambda, it almost achieves the same performance as the plain Java lambda. It responds in around 25ms while the other version does the same in 20ms.

Considering the rich feature set Spring provides – that I haven’t used in the example – if you can keep your lambda functions warm, I think it’s definitely an option to use. If you’re a Spring fan, and you like the easy configuration, probably it’s okay to take this few extra millisec added latency and save time on development and maintenance. Of course I’m not suggesting that you should use it in a very low-latency environment, but if that’s the case, generally speaking Java is not the best option out there for this use-case.

Conclusion

As always, choosing the technology for a particular use-case is a trade-off, and shall be evaluated based on pros and cons. Often for APIs we don’t really care about an added 5-10ms latency, so in my opinion it’s safe to use Spring Boot for even serverless computing, however you have to keep in mind that these lambda functions shall be kept warm to provide a consistent response time.

You can find the full code on GitHub, and make sure to follow me on Twitter for more content.

Leave a Reply

Your email address will not be published. Required fields are marked *