Uncategorized

Serverless Spring MVC with AWS Lambda

Overview

This article will cover a few things. First, we’ll demonstrate how to deploy a Spring Boot MVC app in an AWS Lambda. Next, we’ll discuss why you would want to do this instead of using Spring Cloud Function. Finally, we’ll dig into Lambda cold starts and how to tune a Spring app to mitigate their impact.

Spring Boot MVC and AWS Lambda

Spring MVC is a widely used Java framework for developing APIs over HTTP. The Spring Boot Web Starter will get you up and running in minutes with a servlet container that’s responding to HTTP requests. Deploying it is as simple as starting the app on a server and exposing the port that the servlet container is listening on.

Let’s review how a server responds to an HTTP request with a java servlet web application. A request reaches a public web application server, which forwards the request to a private application server that’s hosting a servlet container. The servlet container routes the request to the servlet context or “web app” that matches the path prefix. The servlet context will initialize the servlet that matches the rest of the URL path if it isn’t active, and finally, the servlet will route the request to the appropriate handler method.

Let’s see what these components would look like in AWS:

But how would this work in a serverless setup? We can update the diagram above to be serverless by replacing the webserver with AWS API Gateway, completely removing the application server, and replacing the Servlet Container with AWS Lambda.

Now we have to figure out how the request will reach Spring’s DispatcherServlet so that it can be routed to its handler method.

The AWS Lambda adapter for Spring

AWS published the “Serverless Java Container” library to support java apps that run in Lambdas. Most java frameworks are included, and Spring Boot 2’s dependency coordinates can be found here.

What’s fantastic about this library is that it truly performs as an adapter without requiring you to change your Spring Boot MVC application’s code. With a few configuration changes and a handler class that delegates to the adapter, you’re good to go. Let’s walk through migrating a typical Spring Boot MVC app to run in a Lambda

Let’s assume that you’re starting off with a Spring Boot Web MVC application that looks like this:

#pom.xml
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.6</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {

    @GetMapping("/hello")
    public String hello() {
        return "hello";
    }
}

The above code snippets may already be familiar to you, but if not, you can peek at Spring’s quick start guide to get you up to speed.

Now how do we plop this code into a Lambda?

First, we’ll need to make some changes to the pom.xml file. Then we’ll need to create a handler method as the entry point for our Lambda. Let’s walk through the steps.

Update the pom.xml

We’ll add the AWS adapter for spring, exclude Tomcat, and change the way that our jar is packaged.

Let’s add the aws-serverless-java-container-springboot2 dependency to the pom:

#pom.xml
<dependency>
    <groupId>com.amazonaws.serverless</groupId>
    <artifactId>aws-serverless-java-container-springboot2</artifactId>
</dependency>

Next, we’ll exclude tomcat from the spring-boot-starter-web dependency. We’re deploying this app to a Lambda runtime environment, which takes the place of the servlet container in a traditional java web application.

#pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

And finally, we’ll package the jar for the AWS Lambda environment. The AWS Lambda environment requires that we use the maven shade plugin to create a single fat jar.

Note that tomcat must be excluded here as well:

#pom.xml

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-shade-plugin</artifactId>
    <version>3.2.4</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>

Gradle folks: AWS provides a sample build.gradle file as well.

Create the handler method

A Lambda’s entry point is a “handler” method. We need to teach this handler method how to delegate the request handling to Spring. To accomplish this, we can use the AWS Java SDK that provides interfaces for creating handler methods. We’re interested in the RequestStreamHandler because it gives us access to the InputStream and OutputStream of the request. When this method is called, our Spring application will need to be running already, so we’ll startup the application context in a static initializer block.

import com.amazonaws.serverless.exceptions.ContainerInitializationException;
import com.amazonaws.serverless.proxy.model.AwsProxyRequest;
import com.amazonaws.serverless.proxy.model.AwsProxyResponse;
import com.amazonaws.serverless.proxy.spring.SpringBootLambdaContainerHandler;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestStreamHandler;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;


public class StreamLambdaHandler implements RequestStreamHandler {
    public static SpringBootLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler;
    static {
        try {
            handler = SpringBootLambdaContainerHandler.getAwsProxyHandler(Application.class);
        } catch (ContainerInitializationException e) {
            // if we fail here. We re-throw the exception to force another cold start
            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);
    }
}

Notice above on line 17 that Application.class is being passed to the SpringBootLambdaContainerHandler#getAwsProxyHandlermethod. This method instantiates the handler adapter and starts your Spring application.

And that’s it! Now let’s package and deploy it.

Deploy the Lambda

We’ll configure our Lambda to be invoked by an API Gateway.

Below is a SAM template.yml file that defines a Lambda resource with an HTTP API as its event source.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Example API written with SpringBoot with the aws-serverless-java-container library

Globals:
  Api:
    # API Gateway regional endpoints
    EndpointConfiguration: REGIONAL

Resources:
  SpringBootMvcFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: com.amazonaws.serverless.sample.springboot2.StreamLambdaHandler::handleRequest
      Runtime: java8
      CodeUri: .
      MemorySize: 1512
      Policies: AWSLambdaBasicExecutionRole
      Timeout: 60
      Events:
        HttpApiEvent:
          Type: HttpApi
          Properties:
            TimeoutInMillis: 20000
            PayloadFormatVersion: '1.0'

Outputs:
  SpringBootMvcApi:
    Description: URL for application
    Value: !Sub 'https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/'
    Export:
      Name: SpringBootMvcApi

Note the Handler property of the Lambda Resource on Line 14. It defines the package and method name that will be called when the Lambda is invoked. As you can see above, the path to our handler method is:

com.amazonaws.serverless.sample.springboot2.StreamLambdaHandler::handleRequest

We’ll use the AWS SAM CLI to deploy these AWS services. First, we’ll build the artifact with the following command:

sam build

You’ll see this console output if the build is successful:

Build Succeeded
Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml
Commands you can use next
=========================
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {stack-name} --watch
[*] Deploy: sam deploy --guided

Then we’ll deploy it with the following command:

sam deploy --guided

You’ll be prompted with a few questions:

  • Stack Name: this can be whatever you want
  • AWS Region: verify that you’re deploying to your preferred region
  • Confirm changes before deploy [y/N]: no
  • Allow SAM CLI IAM role creation [Y/n]: yes
  • Disable rollback: no
  • SpringBootMvcFunction may not have authorization defined, Is this okay?: yes
  • Save arguments to configuration file: no

Once the deployment is completed, the SAM CLI will print out the stack’s outputs, including the new application URL. You can use curl or a web browser to make a call to the URL after appending the path to your endpoint. In our case, we’ll append /hello

...
---------------------------------------------------------------------------------------------------------
OutputKey-Description                        OutputValue
---------------------------------------------------------------------------------------------------------
SpringBootMvcApi - URL for application       https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/
---------------------------------------------------------------------------------------------------------

$ curl https://xxxxxxxxxx.execute-api.us-west-2.amazonaws.com/
$ curl https://xxxxxxxxxx.execute-api.us-west-1.amazonaws.com/hello
hello

Troubleshooting

For troubleshooting, you can view the full working sample on Github here.

Spring Cloud Function

Why not Spring Cloud Function Web? What are the differences?

Spring Cloud Function provides a cloud-provider agnostic programming model for developing serverless applications. The web programming model embraces a novel functional approach to composing endpoint definitions, instead of the traditional @Controller classes most Spring Boot MVC developers are accustomed to. There isn’t any notion of a servlet and there’s no access to the underlying HTTP request and response. Instead of a DispatcherServlet, there’s a Routing Function. So there isn’t a way to drop in an existing Spring Boot MVC app into a Spring Cloud Function Web application — yet. There’s a feature request for it though, and you can keep track of it on the Spring Cloud Function Github here. That GitHub issue inspired this blog article.

With that said, I would use Spring Cloud Function on a new project, rather than Spring Boot MVC. The benefit of being cloud-agnostic and the flexibility of the functional paradigm fits well into an asynchronous messaging architecture, while still supporting basic point-to-point HTTP communication.

Cold starts

A common argument against using Spring Boot in a Lambda is that the time it takes to start the application impacts the response time when the Lambda starts “cold”.

AWS has an excellent blog series explaining cold starts and how to mitigate their impact. I highly recommend reading it if you’re thinking about how cold starts impact your business.

For starters, let’s touch on the performance and cost trade-offs between different languages regarding startup time and execution time.

Runtimes like C# and Java have much slower initialization times than Node.js or Python, but faster execution times once initialized.

This impacts your cost because Lambdas are billed for both duration and memory utilization. Your choice of language is driven by what problem you want to solve: slower startup times, or slower execution times; and then memory footprint. AWS Lambda supports custom runtimes for any language. and here’s a great article that has benchmarks for cold and warm starts for the most popular languages.

AWS Lambda battle 2021: performance comparison for all languages (cold and warm start).

So, if you use Node, your Lambda may start faster, but your request processing time is slower and uses more memory, so you’re paying more for slower response times. Choosing java means you’ll need to spend development time tuning your startup times to benefit from faster execution time during cold starts, otherwise you’ll pay extra during cold starts that impose a slower response time. When Lambdas scale to meet a spike in demand, a faster startup time yields faster input performance. For example, a Node Lambda will spin up new instances faster than Java will in order to pull a spike of messages off of an SQS Queue. Output performance depends on whether your downstream IO is blocking or not. If it’s blocking, a faster processing time will yield better performance, whereas if it’s non-blocking, faster scalability will yield better performance. If you choose Go or Rust, well, actually you’re good on both cold and warm starts.

But how much does it matter to your project? Whatever language you choose, you’ll still gain the cost and scalability benefits of going serverless. My opinion is that as long as you’re meeting the response times in your SLA, you might as well save on development costs too by choosing the language that you’re currently most productive with. That’s the beauty of these serverless microservices: a single organization isn’t bound to a single language or integration pattern for every component of its infrastructure.

For Spring, there are several startup optimizations that you can leverage. Covering them fully in this article would be out of scope, but there are a few resources listed below that offer guidance on the topic:

I would implement the following optimizations out-of-the-box because they come with no trade-offs:

  • Initialize your Spring application in a static block. This will cause Lambda to start the app as it starts the JVM, giving you better performance out of the gate.
  • Compile-time bean wiring with the Spring Context Indexer. Dependency injection with Spring can have a significant impact on your function’s cold start time. To address this, you can include the spring-context-indexer dependency to generate a list of candidate components at compile time.
  • Asynchronous initialization. Great first solution if you’re migrating a hefty monolith, but indicates a need for additional optimizations.
  • Lambda layers. One contributing factor in cold starts is the time it takes for AWS Lambda to download your code. You can reduce the size of your package by putting some of your dependencies in a Lambda Layer.
  • Lazy load beans that aren’t needed at startup.

Optimizations that have trade-offs

  • Avoiding component scans: you’ll need to @Import your @Configuration classes, which adds nominal cognitive overhead during development, but it’s not a big deal. I’d use the context indexer instead.
  • Avoiding custom @ControllerAdvice classes to skip initializing the handlers at startup. Let the runtime exceptions bubble up to the Lambda container and exclusively use service level error handling and automatic retries. My hesitation is that I use @ControllerAdvice classes to ensure that I’m only exposing predefined exceptions to the client, but there’s probably an alternative solution that would work with this optimization.
  • Explicitly declare your bean’s dependencies with @Autowired. I’ve become accustomed to @Autowired being optional. I’ve been using that feature in tandem with Lombok’s @RequiredArgsConstructor. I’d prefer the context indexer to take care of dependency mapping at compile time.
  • Avoiding Constructor Injection by Name. I like Lombok’s @RequiredArgsContructor, so I’d avoid adding @ConstructorProperties in favor of the context indexer.
  • Warmup events. The hope is that pinging a lambda at a regular interval will keep a lambda warm for you, but this approach actually doesn’t guarantee an event will go to a warm container. What gets you closer to this behavior is selecting the provisioned concurrency billing mode, which allows you to pay for a fixed amount of warmed instances to be ready at all times. This still doesn’t help you during spikes when the number of needed lambda containers is higher than your provisioned amount; each new container initialization will be impacted by a cold start. So, while keeping a few lambda instances ready at all times for an additional cost will mitigate the number of cold starts, it won’t eliminate them, regardless of whether you’re pinging your lambda at intervals or provisioning a minimum number of warm instances.

Conclusion

In this article, we’ve gone through how to migrate your Spring MVC app to run in an AWS Lambda, why you might use Spring Cloud Function for a new project, and demonstrated how to ensure SLA compliance by mitigating the impact of slow application start times during a cold start.

If you enjoyed this or have any questions, leave a comment.

Author

Tyler Carpenter-Rivers