4

Java AWS Lambda Container Image Support (Complete Guide)

 3 years ago
source link: https://rieckpil.de/java-aws-lambda-container-image-support-complete-guide/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Java AWS Lambda Container Image Support (Complete Guide)

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 make use of the latest and greatest Java language features. For this to work, we'll create our own Docker image that contains both the runtime and our Java code plus dependencies.

Maven Project Setup For Java And AWS Lambda

We're going to use the following Maven project for our Java 15 AWS Lambda function:

<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:

$ 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:

$ jar tf target/java-aws-lambda-custom-image.jar              
META-INF/
META-INF/MANIFEST.MF
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.

Our Java AWS Lambda Function

For demonstration purposes, let's use the following RequestHandler that makes use of the text block feature of Java 15:

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,
    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 a size of less than 10 GB. 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:

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 is 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:

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 quite 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 locally test our images.

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:

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:

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:

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

Deploying the AWS Lambda Function With 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:

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:

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. As an alternative, we can also use the AWS SAM (Serverless Application Model) framework or AWS CloudFormation/CDK.

Serverless already supports the specification of a container image instead of a traditional .jar or .zip file:

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:

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 also 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. On my local machine, everything was working as expected 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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK