Choosing a packaging mechanism for Java based AWS Lambda functions

Maximilian Schellhorn
My Local Farmer Engineering
8 min readJun 14, 2022

This post describes how to run serverless Java functions on AWS Lambda via a managed runtime, a custom runtime or as a container image and the motivation & considerations for each approach.

Disclaimer
I Love My Local Farmer is a fictional company inspired by customer interactions with AWS Solutions Architects. Any stories told in this blog are not related to a specific customer. Similarities with any real companies, people, or situations are purely coincidental. Stories in this blog represent the views of the authors and are not endorsed by AWS.

Packaging Java based AWS Lambda functions

When building Serverless applications, AWS Lambda provides a variety of options to package & deploy your code. We can use an AWS managed runtime that is built around a combination of operating system, programming language, and software libraries that are subject to maintenance and security updates. As an alternative we can create our own custom runtime or deploy the application as a container image. This is useful when we want to use specific Java versions, corporate base images, familiar container tooling or require a bigger package size. In addition, within these options we can choose how to package the application (uber-jar vs. zip) or decide to use a custom container image instead of an AWS Lambda base image.

At I Love My Local Farmer, we wanted to evaluate the different options to choose the most appropriate one for our case. We have implemented 5 different versions of our Delivery API and used the CreateSlots feature as an example for this blog post to evaluate the respective trade-offs. We are using the AWS CDK to automate the deployments and created a PackagingApi that provides access to the functions via different API endpoints:

PackagingApi architecture

Hint: All the functions use TieredCompilation to improve performance. You can find the Packaging-API Code here.

1. Managed Runtime — Zip

The first deployment method we explored is using the managed AWS runtime Java 11 with a zip file as explained in the docs. The zip file contains the application classes and the dependencies are bundled in a separate /lib folder. In Gradle, this can be achieved via the Zip-Task:

task buildZip(type: Zip) {
from compileJava
from processResources
into('lib') {
from configurations.runtimeClasspath
}
}
build.dependsOn buildZip

When executing ./gradlew clean build you will find the final zip file in the /distributions folder.

The zip file can now be used as a deployment package on AWS Lambda. You can find the automated build & deployment as part of the AWS CDK code here.

Pros:

  • We are using the managed AWS runtime environment
  • Simple tooling support available for both Gradle & Maven
  • We can avoid classpath and merging conflicts (see uber-jar)

Cons:

  • We are limited to a total of 250MB in package size (unzipped)
  • Popular frameworks are usually providing an uber-jar (Micronaut, Spring) so we would have to add an additional build step to create the zip file

2. Managed Runtime — uber-jar

As an alternative, we can deploy the application as an uber-jar. An uber-jar is a deployment package that bundles all the necessary application & library code into a single artifact. In contrast to the zip approach, the dependencies do not reside in a separate lib folder and are not packaged as separate jars. For building an uber-jar with Gradle, we can make use of the ShadowJar plugin:

shadowJar {
archiveBaseName.set('lambda-uber')
transform(Log4j2PluginsCacheFileTransformer)
}
build.dependsOn shadowJar

We will get a lambda-uber-all.jar as a result of ./gradlew clean build in the /libs folder. In addition, when aggregating multiple classes and resources into a single uber-jar there can be a potential overlap of dependencies. For example, to address this issue in Log4j we can make use of Transformers to take care of the merging process.

The deployment is similar to the zip file and you can find the AWS CDK code here.

Pros:

  • We are using the managed AWS runtime environment
  • We are using a familiar packaging mechanisms that is used in popular frameworks with advanced tooling support (See Maven Shade plugin, Gradle ShadowJar)
  • We can use additional optimisation techniques such as minimizeJar to further reduce the package size.

Cons:

  • Depending on the applications libraries we have to identify a merging strategy (See Log4j Transformer)
  • We are limited to a total of 250MB in package size (unzipped)

3. Container Runtime — Base Image

So far we have explored the options available for the managed AWS runtimes. However, we can also deploy a function as a container image. In this example we are using one of the AWS Lambda provided base images.

The following Dockerfile uses a multi-stage build. The first stage builds the application and the second stage uses the final artifacts in a fresh version of the AWS Lambda base image. In this example we are going to use the uber-jar but you can alternatively also use the zip-like approach as mentioned here.

FROM --platform=linux/amd64 amazonlinux:2 as builderRUN yum -y update
RUN yum install -y java-11-amazon-corretto
ENV JAVA_HOME="/usr/lib/jvm/java-11-amazon-corretto.x86_64"
COPY ApiHandlers .
RUN ./gradlew clean build
FROM public.ecr.aws/lambda/java:11COPY --from=builder build/libs/lambda-uber-all.jar ${LAMBDA_TASK_ROOT}/lib/CMD [ "com.ilmlf.delivery.api.handlers.CreateSlots::handleRequest"]

To deploy the container image function via AWS CDK, we can make use of the DockerImageFunction construct. This will automatically upload the container image to Amazon ECR during the deployment phase. See AWS CDK code and the Dockerfile.

Pros:

  • We can use familiar container tooling support. Apart from AWS CDK we can also use the docker-cli directly to work with the images
  • AWS Lambda base images have a good caching strategy (Source)
  • We can create functions up to 10GB of package size vs. 250 MB on the zip / uber-jar approach.
  • We can still use the environment tools options to modify the runtime (JAVA_TOOLS_OPTIONS)
  • We do not need an additional Runtime interface client (See Custom Runtime)

Cons:

  • We rely on a pre-defined image which gives us less flexibility on the environment configuration compared to a custom runtime
  • We can’t use Lambda Layers directly in the standard configuration but would need to work with them in the container image itself (See Working with Lambda layers and extensions in container images)
  • In contrast to the managed runtimes, we are now also billed for the initialisation time of the function which increases cost. In addition, charges for storing the image in Amazon ECR apply.

4. Container Runtime — Custom Image

As an alternative we, can also use our own base image. However, as the application still needs to integrate with the AWS Lambda Runtime API, we have to include an additional library that takes care of the communication. AWS provides language-specific Runtime-Interface Clients (RIC) that provide this capability out of the box. We simply have to add the aws-lambda-java-runtime-interface-client to the dependencies in the build.gradle file:

implementation group: 'com.amazonaws', name: 'aws-lambda-java-runtime-interface-client', version: '2.1.0'

In this example, we are using the OpenJDK image. Similar to the previous approach it uses a multi-stage build to first build the application with the full JDK module — while for the execution we only need the JRE.

FROM openjdk:11-jdk-slim as builderCOPY ApiHandlers .
RUN ./gradlew clean build
FROM openjdk:11-jre-slimCOPY --from=builder build/libs/lambda-uber-all.jar .ENTRYPOINT [ "java", "-cp", "./*", "-XX:TieredStopAtLevel=1", "com.amazonaws.services.lambda.runtime.api.client.AWSLambda" ]
CMD [ "com.ilmlf.delivery.api.handlers.CreateSlots::handleRequest" ]

To deploy the container image function via AWS CDK we can use the same DockerImageFunction construct as before. See the AWS CDK code here and the Dockerfile.

Pros:

  • We can use our own internal base images which allows us to stick to corporate guidelines or compliance requirements
  • Depending on the image we are using, we might have a smaller container image size (For example: 103 MB with the openjdk:11-jre-slim image vs. 198 MB for the AWS Lambda base image — including the application)
  • We have great flexibility on configuring the environment
  • We can use the latest Java language versions that the managed runtime or base container images do not provide yet or apply our own patching cycle

Cons:

  • We need to add the Custom Runtime Interface Client as an additional dependency and configure the entrypoint accordingly
  • We have to take care of patching and securing the custom image
  • Similar to the base image we can’t use the AWS Lambda Layers via configuration
  • We are also billed for the init duration of the function
  • AWS Lambda base images might have a better caching strategy (Source)

5. Custom Runtime

Finally, AWS offers the possibility to provide a custom runtime as a zip file. To describe the process AWS recently published a blog post on how to create a custom Java runtime for AWS Lambda. Similar to the custom image we also have to use the Runtime-Interface Client to communicate with the AWS Lambda Runtime API. In the following example, the Dockerfile is only used as a build mechanism to provide the final runtime.zip. This can be used later as the AWS Lambda deployment package.

FROM --platform=linux/amd64 amazonlinux:2RUN yum -y update
RUN yum install -y java-11-amazon-corretto zip
ENV JAVA_HOME="/usr/lib/jvm/java-11-amazon-corretto.x86_64"
COPY ApiHandlers .
RUN ./gradlew clean build --no-daemon
RUN jdeps -q --ignore-missing-deps --multi-release 11 --print-module-deps \
build/libs/lambda-uber-all.jar > jre-deps.info
RUN jlink --verbose --compress 2 --strip-debug --no-header-files --no-man-pages --output /jre11-slim \
--add-modules $(cat jre-deps.info)
RUN /jre11-slim/bin/java -Xshare:dump# Package everything together into a custom runtime archive
WORKDIR /
RUN cp /resources/bootstrap bootstrap
RUN chmod 755 bootstrap
RUN cp /build/libs/lambda-uber-all.jar function.jar
RUN zip -r runtime.zip bootstrap function.jar /jre11-slim

As you can see from the Dockerfile, we have to provide a bootstrap.sh file to tell AWS Lambda how to start the application:

#!/bin/sh
$LAMBDA_TASK_ROOT/jre11-slim/bin/java \
--add-opens java.base/java.util=ALL-UNNAMED \
-XX:+TieredCompilation \
-XX:TieredStopAtLevel=1 \
-XX:+UseSerialGC \
-cp function.jar com.amazonaws.services.lambda.runtime.api.client.AWSLambda "$_HANDLER"

To automate the bundling and deployment process we can make use of BundlingOptions in AWS CDK. See the full example code here and the Dockerfile.

Pros:

  • We can use the latest Java versions that the managed runtime or base container images do not provide yet or apply our own patching cycle
  • We have great flexibility on configuring the environment
  • We can use the the zip file as deployment package and do not need a container registry
  • We can use the standard Lambda Layers configuration
  • We can distribute the custom runtime as a layer on its own

Cons:

  • We are restricted by the 250 MB unzipped maximum package size limit
  • We have to take care of the bundling process on our own (This can be automated)
  • We need additional configuration files such as bootstrap.sh and the runtime interface client
  • We have to take care of patching and securing the custom runtime
  • We are also billed for the initialisation duration

Considerations & Outlook

As you can see, we have a variety of options to build, package & deploy a serverless Java function on AWS Lambda. To identify the right approach you have to inspect the requirements of your project & application. If you do not require extensive configuration or experimental language features and prefer a simpler and relying setup — use the managed runtime or the base container images. If you rely on the latest Java runtime version with advanced environment configurations — use a custom runtime or the custom container image.

In the case of ILMLF we like to keep things managed. We do not need the very latest Java version or advanced tooling and configuration. Therefore we will be using the managed AWS Runtime with Java 11. Depending on our projects dependencies, size and frameworks requirements, we will decide on the zip or uber-jar approach.

You can find all the examples (Dockerfiles, AWS CDK code and setup instructions) in the Github repository. Feel free to experiment with your favorite function configuration and lets us know what is your favorite packaging mechanism.

--

--