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 Book
s that will be returned in the response. And in the handleRequest
method, the ApiGatewayProxyResponse
is being returned with HTTP 200 and the respective Book
s.
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.