My original plan was to demo the container image support of AWS Lambda with a Java example that uses Selenium to scrape web pages. For Python and Node.js, there are a lot of examples available on the internet. I failed to get Chrome running for a Java AWS Lambda function with a custom Docker image.
Nevertheless, I want to demo how you can customize your own Java runtime for AWS Lambda by providing your Java function as a container image. As AWS Lambda currently supports Java 8 & Java 11 as runtimes, let's deploy Java 15 code as we want to use the latest and greatest Java language features. We'll create our own Docker image that contains both the runtime and our Java code plus dependencies for this to work.
Maven Project Setup For Java And AWS Lambda
We're going to use the following Maven project for our Java 15 AWS Lambda function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>de.rieckpil.blog</groupId> <artifactId>java-aws-lambda-custom-image</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <maven.compiler.source>15</maven.compiler.source> <maven.compiler.target>15</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <aws-lambda-java-runtime-interface-client.version>1.0.0</aws-lambda-java-runtime-interface-client.version> </properties> <dependencies> <dependency> <groupId>com.amazonaws</groupId> <artifactId>aws-lambda-java-runtime-interface-client</artifactId> <version>${aws-lambda-java-runtime-interface-client.version}</version> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.7.0</version> <scope>test</scope> </dependency> </dependencies> <build> <finalName>${project.artifactId}</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <version>3.1.2</version> <configuration> <includeScope>runtime</includeScope> </configuration> <executions> <execution> <id>copy-dependencies</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.0.0-M5</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> </plugin> </plugins> </build> </project> |
Wait, where is the aws-lambda-java-core
dependency that includes the Lambda request handler interfaces? It's part of the aws-lambda-java-runtime-interface-client
(what a long artifactId
) and transitively included in our project.
More about the runtime-interface-client
and why we need it in one of the upcoming sections.
There's already another difference compared to deploying Java functions to AWS Lambda the traditional way. When we use the Java 8 or Java 11 Lamda runtime from AWS, we ship our Java code as a shaded .jar
or .zip
file. Here we're not creating a shaded .jar
and rather use the maven-dependency-plugin
.
This Maven plugin copies the dependencies of our project to target/dependency
when building the project with mvn package
. We configure the maven-dependency-plugin
to only include runtime
dependencies as otherwise, our test libraries would end up in target/dependecy
and get shipped to AWS:
1 2 3 4 5 6 7 |
$ ls -lah target/dependency total 3,5M drwxrwxr-x 2 rieckpil rieckpil 4,0K Feb 8 14:22 . drwxrwxr-x 10 rieckpil rieckpil 4,0K Feb 8 14:22 .. -rw-rw-r-- 1 rieckpil rieckpil 7,3K Jan 25 22:09 aws-lambda-java-core-1.2.0.jar -rw-rw-r-- 1 rieckpil rieckpil 1,3M Jan 25 22:09 aws-lambda-java-runtime-interface-client-1.0.0.jar -rw-rw-r-- 1 rieckpil rieckpil 2,2M Jan 25 22:09 aws-lambda-java-serialization-1.0.0.jar |
Hence our the actual .jar
file for our project becomes quite small (some kilobytes) as it only contains our compiled Java classes and Maven specific metadata:
1 2 3 4 5 6 7 8 9 10 11 12 |
$ jar tf target/java-aws-lambda-custom-image.jar META-INF/ META-INF/MANIFEST.MF de/ de/rieckpil/ de/rieckpil/blog/ de/rieckpil/blog/Java15Lambda.class META-INF/maven/ META-INF/maven/de.rieckpil.blog/ META-INF/maven/de.rieckpil.blog/java-aws-lambda-custom-image/ META-INF/maven/de.rieckpil.blog/java-aws-lambda-custom-image/pom.xml META-INF/maven/de.rieckpil.blog/java-aws-lambda-custom-image/pom.properties |
This separation becomes quite important when building our Docker image as we can effectively use Docker's caching mechanism.
The Java AWS Lambda Function
For demonstration purposes, let's use the following RequestHandler
that makes use of the text block feature of Java 15:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Java15Lambda implements RequestHandler<Void, String> { @Override public String handleRequest(Void input, Context context) { var message = """ Hello World! I'm using one of the latest language feature's of Java. That's cool, isn't it? Kind regards, Duke """; return message; } } |
This Lambda function takes no input and returns a static message. For a more realistic and useful Java & AWS Lambda example, take a look at the Thumbnail Generator.
The Docker Image For Our Java Lambda Function
One of the requirements we have to fulfill when deploying our function as a container is to provide a container image with less than 10 GB in size. We're not limited to use Docker for this purpose as AWS Lambda also supports images that are compatible with the manifest format of the Open Container Initiative (OCI).
When creating our container image, we have two options:
- use an AWS Lambda base that already includes the relevant configuration. What's left is to install additional packages and copy our source code.
- use a custom base image, aka. the DIY (do it yourself) approach. This brings the most flexibility, but we have to ensure compatibility with the AWS Lambda runtime.
With a bring your own container image approach, how does the AWS Lambda Runtime know what to invoke inside our container?
That's where the Runtime Interface Client comes into play. The AWS Lambda base images already include this runtime interface client that manages the interaction between Lambda and our code. When using our own image, we have to add such a runtime interface client. Otherwise, we won't be able to receive invocations from AWS Lambda.
For our Java project, it's enough to include the additional dependency (aws-lambda-java-runtime-interface-client
) and make sure it's part of the classpath later on.
Let's use an OpenJDK base image from AdoptOpenJDK for our custom Docker image:
1 2 3 4 5 6 7 8 9 10 11 12 |
FROM adoptopenjdk/openjdk15:ubuntu-jre # (Optional) Install any additional package RUN apt-get update && \ apt-get upgrade -y && \ apt-get install -y wget COPY target/dependency/* /function/ COPY target/java-aws-lambda-custom-image.jar /function ENTRYPOINT [ "/opt/java/openjdk/bin/java", "-cp", "/function/*", "com.amazonaws.services.lambda.runtime.api.client.AWSLambda" ] CMD ["de.rieckpil.blog.Java15Lambda::handleRequest"] |
Our Docker ENTRYPOINT
is a good old java -cp
. With -cp
we specify the classpath and point to our /function
folder that contains both our application.jar
and all dependencies. We execute the AWSLambda
class (part of the Runtime Interface Client) that acts as a bridge between our code and AWS Lambda. This AWSLambda
class expects the location of our handler function as the first argument.
In case you're wondering why we add ENTRYPOINT
and CMD
to our image, there's a great blog post on the AWS Open Source Blog that demystifies this puzzling question.
The order of the two COPY
steps are important here. Once we have built our image for the first time, Docker will only re-execute the steps of our Dockerfile
if something changed when building a new image (e.g., a change in the function). Most of the time, the files inside target/dependency
stay the same, and only our implementation changes.
While this brings little benefit when building the Docker image locally (copying a set of .jar
files to the Docker daemon is quite fast), the real benefit comes when pushing our image. Once we pushed the first version of our Docker image to our registry, all subsequent docker push
attempts only push layers that have changed. In our example, this will be just some KB whenever we touch the source code of our Lambda function:
1 2 3 4 5 6 7 |
Step 3/6 : COPY target/dependency/* /function/ ---> Using cache ---> 28a86a1cce16 Step 4/6 : COPY target/java-aws-lambda-custom-image.jar /function ---> 1d776f65975b Step 5/6 : ENTRYPOINT [ "/opt/java/openjdk/bin/java", "-cp", "/function/*", "com.amazonaws.services.lambda.runtime.api.client.AWSLambda" ] ---> Running in 9a50102b41f |
Testing the Container Image Locally
As the whole AWS Lambda environment is a black box for us, it's hard to debug and test a new image. Fortunately, AWS Lambda provides an elegant solution to test our image locally: AWS Lambda Runtime Emulator (RIE). This emulator is a proxy for the Lambda Runtime API that we can use to test our images locally.
When using an AWS Lambda base image, the RIE is already part of the Docker image. We can either add this to our custom image or use a standalone approach and install the emulator on our machine.
Let's use the second approach. The download and installation instructions for each platform are available in the README of the Runtime Interface Emulator. As the next step, we have to create the first Docker image for our Lambda function:
1 2 |
mvn package docker build . -t java-aws-lambda-custom-image:1 |
Right after our first successful Docker image build, we can run the following command:
1 2 3 4 |
docker run -v ~/.aws-lambda-rie:/aws-lambda -p 9000:8080 \ --entrypoint /aws-lambda/aws-lambda-rie \ java-aws-lambda-custom-image:1 \ /opt/java/openjdk/bin/java -cp '/function/*' com.amazonaws.services.lambda.runtime.api.client.AWSLambda de.rieckpil.blog.Java15Lambda |
This runs our Docker container and mounts the RIE executable to our container as the entry point. As this overrides our original ENTRYPOINT
we pass it (including the fully-qualified class name of our Lambda function) as a last argument to the docker run
command.
We can now send HTTP POST requests to a specific endpoint of our container to invoke the Lambda function:
1 |
curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}' |
Deploying the AWS Lambda Function as an Image
As a first step, we need an ECR (Elastic Container Registry) to host our Docker Image. We can use the AWS CLI or CloudFormation/CDK to create this resource inside AWS:
1 2 |
aws ecr get-login-password --region eu-central-1 | docker login --username AWS --password-stdin 547530709389.dkr.ecr.eu-central-1.amazonaws.com aws ecr create-repository --repository-name java-aws-lambda-custom-image --image-scanning-configuration scanOnPush=true --region eu-central-1 |
When replication these AWS CLI steps, make sure to use your AWS region and AWS account id.
Next, we can tag our Docker image and then push it to our ECR:
1 2 |
docker tag java-aws-lambda-custom-image:1 547530709389.dkr.ecr.eu-central-1.amazonaws.com/java-aws-lambda-custom-image:1 docker push 547530709389.dkr.ecr.eu-central-1.amazonaws.com/java-aws-lambda-custom-image:1 |
We're going to use Serverless to deploy our AWS Lambda function. We can also use the AWS SAM (Serverless Application Model) framework or AWS CloudFormation/CDK as an alternative.
Serverless already supports the specification of a container image
instead of a traditional .jar
or .zip
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
service: java-aws-lambda-custom-image provider: name: aws runtime: java11 profile: serverless-admin region: eu-central-1 timeout: 10 memorySize: 1024 logRetentionInDays: 7 lambdaHashingVersion: 20201221 functions: customRuntime: image: 547530709389.dkr.ecr.eu-central-1.amazonaws.com/java-aws-lambda-custom-image:1 |
What's left is to deploy everything with serverless deploy
. Whenever a new version of our Docker image is available, we can change the image tag inside our serverless.yml
file and deploy the changes with serverless deploy -f customRuntime
.
I'm not going into much detail about Serverless. This is already covered by other Java AWS Lambda articles on my site:
- Java AWS Lambda with Serverless and Maven – Thumbnail Generator
- Java AWS Lambda with Spring Cloud Function
- Going Serverless with Java | YouTube Series
Summary
With this deployment approach, we run any Java code on AWS Lambda with our own container image. This gives us the flexibility to tweak the underlying operating system and its configuration to our needs. The Java Runtime Interface Client takes care to bridge between the Lambda Runtime and the code inside our container. Testing the image locally with the Runtime Interface Emulator is almost no effort (once you get the docker run
command right). In addition, we can bundle the emulator to our custom image and don't have to mess with this long command.
However, successfully invoking the function locally doesn't guarantee success on AWS Lambda. There are still some slight differences (e.g., the container runtime, access to the filesystem, etc.) between AWS Lambda and our local machine. I learned this the hard way when trying to get the Chrome + Chromedriver + Selenium running on AWS Lambda. Everything was working as expected on my local machine, but on AWS Lambda, the whole thing blows up. I'll definitely write a new blog post once I get this setup running on AWS Lambda.
You can find the source code for this Java AWS Lambda example on GitHub.
Have fun deploying your Java functions with a container image to AWS Lambda,
Philip