Java AWS Lambda Container Image Support (Complete Guide)

Last Updated:  April 9, 2023 | Published: February 10, 2021

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:

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:

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:

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:

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:

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:

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:

Right after our first successful Docker image build, we can run the following command:

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:

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:

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:

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:

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:

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

>