Running AWS Lambda written in Java with Docker

To make 2020 a little bit less creepy than it was AWS has announced Container Image Support for AWS Lambda. That’s one small step for AWS, but one giant leap for everyone who uses Lambda on a daily basis. First of all, they increased container image size from 250 MB to 10 GB which opens completely new possibilities, for instance, we can make data science models serverless now. Container Image Support also allows developers to pack their code into containers and deploy it using Container registry. It is awesome, but the biggest benefit to me is that we can now run Lambdas locally without any additional tools or magical rituals! If you are unfamiliar with AWS Lambda please check AWS official documentation. In this article I will demonstrate how it works in practice, so let’s get started!

Application

The first step on our journey is the application itself, so we can start by creating a simple Java app and assemble it using Gradle build tool. To do that we need to install Gradle, execute gradle init command and follow initialization steps:

Gradle project initialization

You can learn more about Java projects initialization on Gradle official website.

The Gradle init process creates a lot of unnecessary information for us, so we will clean it up a little bit. We will start with build.gradle file, let’s replace the content with following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
plugins {
id 'java'
}

group 'dev.sopin' // You can use your group instead of this one, like: com.example
version '1.0-SNAPSHOT'

repositories {
mavenCentral()
}

dependencies {
testImplementation 'junit:junit:4.12'
}

This code allows creating the most basic application, however, it is not enough for what we are going to achieve, so let's add dependency to AWS SDK to convert our application into AWS Lambda:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
plugins {
id 'java'
}

group 'dev.sopin' // Please use your group instead of this one, like: com.example
version '1.0-SNAPSHOT'

repositories {
mavenCentral()
}

dependencies {
implementation 'com.amazonaws:aws-lambda-java-core:1.2.1'

testImplementation 'junit:junit:4.12'
}

Now, when AWS Lambda-Core SDK is imported, we can start changing our code. First of all, we will change the folder structure to correspond group parameter we declared inside build.gradle file. Currently our folder structure looks like following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.
├── build.gradle
├── gradle
│   └── wrapper
│   ├── gradle-wrapper.jar
│   └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src
├── main
│   ├── java
│   │   └── dev
│   │   └── sopin
│   │   └── App.java
│   └── resources
└── test
├── java
│   └── dev
│   └── sopin
│   └── AppTest.java
└── resources

We can use an IDE like IntelliJ IDEA to perform this operation, or, alternatively, we can use command line:

1
2
$ mkdir ./src/main/java/dev
$ mv ./src/main/java/src ./src/main/java/dev/sopin/

You can use different names for packages, but make sure to keep it consistent everywhere.

After the change our folder structure should be following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.
├── build.gradle
├── gradle
│   └── wrapper
│   ├── gradle-wrapper.jar
│   └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src
├── main
│   ├── java
│   │   └── dev
│   │   └── sopin
│   │   └── App.java
│   └── resources
└── test
├── java
│   └── dev
│   └── sopin
│   └── AppTest.java
└── resources

As you can see, when Gradle initializes a new project it creates App class. Initially this class contains following code:

1
2
3
4
5
6
7
8
9
10
11
package dev.sopin;

public class App {
public String getGreeting() {
return "Hello world.";
}

public static void main(String[] args) {
System.out.println(new App().getGreeting());
}
}

Pay attention to package declaration and make sure it corresponds to your folder structure and group parameter from build.gradle.

This class represents classic Java application with the main function serving as an entry point. However, AWS Lambda is not a regular application. It is a piece of code that runs inside Lambda execution environment, so it must satisfy environment contracts. Let's adjust code of our application to make it AWS Lambda:

1
2
3
4
5
6
7
8
9
10
11
12
package dev.sopin;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

public class App implements RequestHandler<Object, String> {

@Override
public String handleRequest(Object input, Context context) {
return "Hello from AWS Lambda!";
}
}

As you can see, we have to implement RequestHandler interface with two generic parameters, the first parameter (Object in our case) is type of Lambda input (request), while the second one (String) is type of output, i.e. response. The RequestHandler interface requires us to implement handleRequest function, which is the main Lambda handler, so it is responsible for request processing. In our case, it simply returns "Hello from AWS Lambda!" for every request it receives.

Gradle also creates a single unit test for our application, so we need to adjust it as well (AppTest.java file):

1
2
3
4
5
6
7
8
9
import org.junit.Test;
import static org.junit.Assert.*;

public class AppTest {
@Test public void testAppHasAGreeting() {
App classUnderTest = new App();
assertNotNull("App should return a greeting", classUnderTest.handleRequest("Hello", null));
}
}

Now, when Lambda code is complete, let's dockerize it.

Dockerizing Lambda

As it was mentioned above, lambdas run inside a Lambda runtime which communicates with Lambda code using the Runtime API. In order to create this communication link we need to use Lambda Runtime Interface Client, so we need to add it into the list of project dependencies in build.gradle file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
plugins {
id 'java'
}

group 'dev.sopin'
version '1.0-SNAPSHOT'

repositories {
mavenCentral()
}

dependencies {
implementation 'com.amazonaws:aws-lambda-java-core:1.2.1'
implementation 'com.amazonaws:aws-lambda-java-runtime-interface-client:1.0.0'

testImplementation 'junit:junit:4.12'
}

Another important step towards dockerized Lambda is proper packaging and dependencies propagation. To ensure that everything is being built correctly and all needed artifacts are being created we need to add new copyDependencies Gradle task to build.gradle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
plugins {
id 'java'
}

group 'dev.sopin'
version '1.0-SNAPSHOT'

repositories {
mavenCentral()
}

dependencies {
implementation 'com.amazonaws:aws-lambda-java-core:1.2.1'
implementation 'com.amazonaws:aws-lambda-java-runtime-interface-client:1.0.0'

testImplementation 'junit:junit:4.12'
}

task copyDependencies(type: Copy) {
from configurations.runtimeClasspath
into 'build/dependencies'
}

build.dependsOn copyDependencies

This task ensures that all project dependencies are stored inside build/dependencies folder once Gradle build is completed.

Now, when our code is complete, we can try building it using gradle build command. The results of build are saved into build folder that is being generated by Gradle automatically with the following structure:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.
├── build
│   ├── classes
│   │   └── java
│   │   ├── main
│   │   │   └── dev
│   │   │   └── sopin
│   │   │   └── App.class
│   │   └── test
│   │   └── dev
│   │   └── sopin
│   │   └── AppTest.class
│   ├── dependencies
│   │   ├── aws-lambda-java-core-1.2.1.jar
│   │   ├── aws-lambda-java-runtime-interface-client-1.0.0.jar
│   │   └── aws-lambda-java-serialization-1.0.0.jar
...

Please make sure that dependencies folder is created.

Our code is completed and built, so it is time to create a docker container.

It is assumed that you have Docker intalled.

To do that we need to create new Dockerfile file with the following content:

1
2
3
4
5
6
7
8
FROM public.ecr.aws/lambda/java:11

# Copy function code and runtime dependencies from Gradle layout
COPY build/classes/java/main ${LAMBDA_TASK_ROOT}
COPY build/dependencies/* ${LAMBDA_TASK_ROOT}/lib/

# Set the CMD to your handler (could also be done as a parameter override outside of the Dockerfile)
CMD [ "dev.sopin.App::handleRequest" ]

With this in place we can execute Docker build command and Docker will create new image for us:

1
docker build . -t app-lambda:latest

Please note that we are using one of AWS base images.

We can run new container based on this image using following command:

1
docker run -p 9000:8080 app-lambda:latest

This will start a new container and expose port 9000. By default, lambda runtime exposes one URL with /functions/function/invocations address, so we can call it using curl:

1
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'

Which should return "Hello from AWS Lambda!" response:

1
2
$ curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'
"Hello from AWS Lambda!"

In practice, to avoid manual overhead and errors, I tend to automate all of these steps (except invocations), for instance, using Shell scripts:

1
2
3
4
5
6
7
#!/bin/bash

gradle build

docker build . -t app-lambda:latest

docker run -p 9000:8080 app-lambda:latest

AWS Credentials

You would often need to pass AWS credentials inside your container to make your lambda work. This can be done using Environment variables or build parameters.

WARNING: Make sure you are using this approach for local Docker images only. Do not use it for images used anywhere else as it is a security threat.

My prefered way of passing AWS credentials into container is Docker build parameters, so we can adjust our Shell script even further:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash

gradle build

docker build . -t app-lambda:latest \
--build-arg AWS_DEFAULT_REGION=us-region-1 \
--build-arg AWS_ACCESS_KEY_ID="YOUR_ACCESSKEY_ID" \
--build-arg AWS_SECRET_ACCESS_KEY="YOUR_SECRET_ACCESS_KEY"

docker run -p 9000:8080 app-lambda:latest

Make sure to replace all parameters with your values.

You can check official documentation to learn more about AWS CLI Configuration Variables. With this said we have a fully working Lambda that can be executed locally.

Bonus: SAML authentication

Sometimes corporations use AD Connector to temporarily authenticate users to AWS Console against corporate Active Directory. In this case you can configure your browser to download temporary credentials for you automatically every time you login to AWS Console using browser extensions:

Credentials file is being downloaded into Downloads folder by default, so we can change Dockerfile to pick it up automatically for us:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/bin/bash

gradle build

export AWS_SECRET_ACCESS_KEY=`grep 'aws_secret_access_key' ~/Downloads/credentials | sed -e 's/aws_secret_access_key =.//' | tail -n 1`
export AWS_SESSION_TOKEN=`grep 'aws_session_token' ~/Downloads/credentials | sed -e 's/aws_session_token =.//' | tail -n 1`
export AWS_ACCESS_KEY_ID=`grep 'aws_access_key_id' ~/Downloads/credentials | sed -e 's/aws_access_key_id =.//' | tail -n 1`

docker build . -t app-lambda:latest

docker run -e AWS_DEFAULT_REGION=ca-central-1 \
-e AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} \
-e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
-e AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN} \
-p 9000:8080 app-lambda:latest

You can read more about it on AWS website.

Conclusion

Running Lambda locally is a great convenience as before this feature we had to use some third party tools or simply add main function to the app code to be able to execute it as a regular application, but this approach was error prone. For instance, the main function doesn’t allow checking HTTP request processing logic. Moreover, containerized approaches can help to unify deployment processes using Container Registry and provide new opportunities that were either impossible, or very difficult and economically unprofitable to implement before.

Last but not least, you can find complete project on GitHub.