1. Overview

In this tutorial, we’ll explore how to deploy a Spring Boot application to AWS Lambda using the Serverless Application Model (SAM) framework.

We may find this approach useful for migrating an existing API server to serverless.

By doing so, we can take advantage of AWS Lambda’s scalability and pay-per-execution pricing model to run our application efficiently and cost-effectively.

2. Understanding Lamdba

AWS Lambda is a serverless computing service provided by Amazon Web Services (AWS). It allows us to run our code without having to provision or manage servers.

One of the key differences between Lambda functions and traditional servers is that Lambda functions are event-driven and have a very short lifespan.

Instead of running continuously like a server, Lambda functions only run in response to specific events, such as an API request, a message on a queue, or a file upload to S3.

We should note that lambdas take time to start up for the first request they serve. This is called a “cold start”.

If the next request comes within a short time, the same lambda runtime may be used, which is called a “warm start”. If more than one request comes concurrently, multiple Lambda runtimes are started.

As Spring Boot has a relatively long startup time compared with the ideal milliseconds for a Lambda, we’ll discuss how this affects performance.

3. Project Setup

So, let’s migrate an existing Spring Boot project by modifying the pom.xml and adding some configuration.

The supported versions of Spring Boot are 2.2.x, 2.3.x, 2.4.x, 2.5.x, 2.6.x and 2.7.x.

3.1. Example Spring Boot API

Our application is composed of a simple API that handles any GET request to the api/v1/users endpoint:

@RestController
@RequestMapping("/api/v1/")
public class ProfileController {

    @GetMapping(value = "users", produces = MediaType.APPLICATION_JSON_VALUE)
    public List<User> getUser() {
        return List.of(new User("John", "Doe", "[email protected]"), 
                       new User("John", "Doe", "[email protected]"));
    }
}

That responds with a list of User objects:

public class User {

    private String name;
    private String surname;
    private String emailAddress;

    //standard constructor, getters and setters
}

Let’s start our application and invoke the API:

$ java -jar app.jar
$ curl -X GET http://localhost:8080/api/v1/users -H "Content-Type: application/json"

The API response is:

[
   {
      "name":"John",
      "surname":"Doe",
      "email":"[email protected]"
   },
   {
      "name":"John",
      "surname":"Doe",
      "email":"[email protected]"
   }
]

3.2. Convert the Spring Boot Application to Lambda via Maven

In order to run our application on Lambda, let’s add the aws-serverless-java-container-springboot2 dependency to our pom.xml file:

<dependency>
    <groupId>com.amazonaws.serverless</groupId>
    <artifactId>aws-serverless-java-container-springboot2</artifactId>
    <version>${springboot2.aws.version}</version>
</dependency>

Then, we’ll add the maven-shade-plugin and remove the spring-boot-maven-plugin.

The Maven Shade Plugin is used to create a shaded (or uber) JAR file. A shaded JAR file is a self-contained executable JAR file that includes all of its dependencies within the JAR itself so that it can be run independently:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.3.0</version>
    <configuration>
        <createDependencyReducedPom>false</createDependencyReducedPom>
    </configuration>
    <executions>
        <execution>
            <phase>package</phase>
            <goals>
                <goal>shade</goal>
            </goals>
            <configuration>
                <artifactSet>
                    <excludes>
                        <exclude>org.apache.tomcat.embed:*</exclude>
                    </excludes>
                 </artifactSet>
            </configuration>
         </execution>
     </executions>
 </plugin>

Overall, this configuration will produce a shaded JAR file during the package phase of the Maven build.

The JAR file will include all the classes and resources that Spring Boot would normally package, with the exception of those from Tomcat. We don’t need to run an embedded web container for use with AWS Lambda.

4. Lambda Handler

The next step is to create a class that implements a RequestHandler.

RequestHandler* is an interface that defines a single method, *handleRequest. There are a few different ways to handle requests, depending on the type of Lambda we’re building.

In this case, we’re processing a request from API Gateway, so we can use the RequestHandler<AwsProxyRequest, AwsProxyResponse> version, where the input is an API Gateway request, and the response is an API Gateway response.

The Spring Boot serverless library, provided by AWS, gives us a special SpringBootLambdaContainerHandler class, which is used to process API calls through Spring, thus enabling a Spring Boot API server codebase to act like a Lambda.

4.1. Startup Timing

We should note that in AWS Lambda, the initialization phase is time-limited to 10 seconds.

If our application takes longer than this to start up, AWS Lambda will timeout and try to start a new Lambda runtime.

Depending on how quickly our Spring Boot Application starts up, we can choose between two ways to initialize our Lambda handler:

  • Synchronous – where the start-up time of our application is much less than the time limit
  • Asynchronous – where the start-up time of our application is likely to take longer

4.2. Synchronous Initialization

Let’s define a new handler in our Spring Boot project:

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

    static {
        try {
            handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class); }
        catch (ContainerInitializationException ex){
            throw new RuntimeException("Unable to load spring boot application",ex); }
    }

    @Override
    public AwsProxyResponse handleRequest(AwsProxyRequest input, Context context) {
        return handler.proxy(input, context);
    }
}

We’re using the SpringBootLambdaContainerHandler to process API Gateway requests and pass them through our application context. We initialize this handler in the static constructor of the LambaHandler class, and we call it from the handleRequest function.

The handler object then invokes the appropriate method in the Spring Boot application to process the request and generate a response. Finally, it returns the response back to the Lambda runtime for passing back to the API Gateway.

Let’s invoke our API via the Lambda handler:

@Test
void whenTheUsersPathIsInvokedViaLambda_thenShouldReturnAList() throws IOException {
    LambdaHandler lambdaHandler = new LambdaHandler();
    AwsProxyRequest req = new AwsProxyRequestBuilder("/api/v1/users", "GET").build();
    AwsProxyResponse resp = lambdaHandler.handleRequest(req, lambdaContext);
    Assertions.assertNotNull(resp.getBody());
    Assertions.assertEquals(200, resp.getStatusCode());
}

4.3. Asynchronous Initialization

Sometimes Spring Boot Application may be slow to start. That’s because, during the start-up phase, the Spring engine builds its context, scanning and initializing all the beans in the codebase.

This process may impact the start-up time and can create a lot of problems in a serverless context.

To solve this problem, we can define a new handler:

public class AsynchronousLambdaHandler implements RequestHandler<AwsProxyRequest, AwsProxyResponse> {
    private SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;

    public AsynchronousLambdaHandler() throws ContainerInitializationException {
        handler = (SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse>) 
          new SpringBootProxyHandlerBuilder()
            .springBootApplication(Application.class)
            .asyncInit()
            .buildAndInitialize();
    }

    @Override
    public AwsProxyResponse handleRequest(AwsProxyRequest input, Context context) {
        return handler.proxy(input, context);
    }
}

This method is similar to the previous one. In this instance, the SpringBootLambdaContainerHandler is constructed in the object constructor of the request handler, rather than the static constructor. As such, it’s executed in a different phase of the Lambda’s start-up.

5. Deploy the Application

AWS SAM (Serverless Application Model) is an open-source framework for building serverless applications on AWS.

After defining a Lambda handler for our Spring Boot Application, we need to prepare all the components for deploying using SAM.

5.1. SAM Template

The SAM template (SAM YAML) is a YAML-formatted file that defines the AWS resources needed to deploy a serverless application. Basically, it provides a declarative way to specify the configuration of our serverless application.

So, let’s define our template.yaml:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Timeout: 30

Resources:
  ProfileApiFunction:
    Type: AWS::Serverless::Function 
    Properties:
      CodeUri: .
      Handler: com.baeldung.aws.handler.LambdaHandler::handleRequest
      Runtime: java11
      AutoPublishAlias: production
      SnapStart:
        ApplyOn: PublishedVersions
      Architectures:
        - x86_64
      MemorySize: 2048
      Environment: 
        Variables:
          JAVA_TOOL_OPTIONS: -XX:+TieredCompilation -XX:TieredStopAtLevel=1 
      Events:
        HelloWorld:
          Type: Api 
          Properties:
            Path: /{proxy+}
            Method: ANY

Let’s discuss some of the fields in our configuration:

  • type – indicates that this resource is an AWS Lambda function defined using the AWS::Serverless::Function resource type.
  • coreUri – specifies the location of the code for the function.
  • AutoPublishAlias – specifies the alias that AWS Lambda should use when automatically publishing a new version of the function.
  • Handler – specifies the lambda handler class.
  • Events – specifies the events that trigger the Lambda function.
  • Type – specifies that this is an Api event source.
  • Properties – for an API event, this defines the HTTP method and paths that the API Gateway should respond to.

5.2. SAM Deploy

It’s time to deploy our application as an AWS Lambda.

The first step is to download and install AWS CLI and then AWS SAM CLI.

Let’s run AWS SAM CLI on the path where the template.yaml is located and execute the command:

$ sam build

When we run this command, AWS SAM CLI will package and build our Lambda function’s source code and dependencies into a ZIP file that serves as our deployment package.

Let’s deploy our application locally:

$ sam local start-api

Next, let’s trigger our Spring Boot service while it’s running via sam local:

$ curl localhost:3000/api/v1/users

The API response is the same as before:

[
   {
      "name":"John",
      "surname":"Doe",
      "email":"[email protected]"
   },
   {
      "name":"John",
      "surname":"Doe",
      "email":"[email protected]"
   }
]

We can also deploy it to AWS:

$ sam deploy

6. Limitations of Using Spring in a Lambda

Although Spring is a powerful and flexible framework for building complex and scalable applications, it may not always be the best choice for use in a Lambda context.

The primary reason for this is that Lambdas are designed to be small, single-purpose functions that execute quickly and efficiently.

6.1. Cold Start

The cold start time of AWS Lambda functions is the time it takes to initialize the function environment before processing an event.

There are several factors that can impact cold start performance for a Lambda function:

  • Package size – a larger package size can result in longer initialization times and lead to slower cold starts.
  • Initialization time – the amount of time it takes for the Spring Framework to initialize and set up the application context.
    This includes initializing any dependencies, such as database connections, HTTP clients, or caching frameworks.
  • Custom initialization logic – it’s important to minimize the amount of custom initialization logic and ensure it’s optimized for cold starts.

We can improve our start-up time using Lambda SnapStart.

6.2. Database Connection Pool

In a serverless environment like AWS Lambda, where functions are executed on-demand, maintaining a connection pool can be challenging.

When an event triggers a Lambda, the AWS Lambda engine can create a new instance of the application. In between requests, the runtime is parked or may be terminated.

Many connection pools hold open connections. This can cause confusion or errors as the pool is reused after a warm start, and it can cause resource leaks for some database engines. In short, standard connection pooling relies on the server continuously running and maintaining the connections.

To address this issue, AWS provides a solution called RDS Proxy, which provides a connection pooling service for Lambda functions.

By using RDS Proxy, Lambda functions can connect to a database without the need for maintaining their own connection pools.

7. Conclusion

In this article, we learned how to convert an existing Spring Boot API Application into an AWS Lambda.

We looked at the library provided by AWS to help with this. Also, we considered how the slower startup time of Spring Boot may affect the way we set things up.

Then we looked at how to deploy the Lambda and test it using the SAM CLI.

As always, the complete source code of the examples can be found over on GitHub.