Introduction
In this two-part blog series, we’re building a serverless order processing API using Astra DB and AWS. As you might recall from the first part, we set ourselves three goals:
- Access Astra DB from within AWS Lambda.
- Write automatic tests for our Astra DB client.
- Set up the Lambda function to use the GraalVM native image runtime.
Let us recall the application architecture and review what’s still missing. So far, we’ve accomplished the first two goals and successfully implemented and tested an Astra Client that we can use inside our AWS Lambda function.
We can now focus on the third goal: implementing the Lambda function and running it inside a GraalVM native image runtime. We’ll also hook it up to API Gateway, so that the end user can call it via an HTTP API. The complete source code is available on GitHub.
Implementation
Lambda handler
In order to process incoming API Gateway requests via AWS Lambda, we need to implement a RequestHandler<APIGatewayV2HTTPEvent, LambdaResponse>
which creates and uses our AstraClient
.
APIGatewayV2HTTPEvent
is implemented in the aws-lambda-java-events
dependency and contains a JSON representation of an HTTP request. LambdaResponse
is a simple data class we wrote that captures HTTP response fields we want to use: body and status code. You can find more information about the event and response format in the official documentation.
The following code shows a very simple handler implementation that decodes the incoming payload into an Order object and stores it in Astra DB. If the operation was successful, we return the respective order ID. Otherwise, we return an error.
public class LambdaHandler implements RequestHandler<APIGatewayV2HTTPEvent, LambdaResponse> {
private static final Gson mapper = new Gson();
private static final AstraClient astraClient = newAstraClientFromEnv();
private static AstraClient newAstraClientFromEnv() {
String astraUrl = System.getenv("ASTRA_URL");
String astraToken = System.getenv("ASTRA_TOKEN");
String astraNamespace = System.getenv("ASTRA_NAMESPACE");
return new AstraClient(URI.create(astraUrl), astraToken, astraNamespace);
}
@Override
public LambdaResponse handleRequest(APIGatewayV2HTTPEvent input, Context context) {
if (input.getRouteKey().startsWith("GET")) {
String orderIdRaw = input.getPathParameters().get("orderId");
UUID orderId = UUID.fromString(orderIdRaw);
Optional<Order> order = astraClient.getOrder(orderId);
if (order.isEmpty()) {
return new LambdaResponse();
}
return new LambdaResponse(order.get());
} else if (input.getRouteKey().startsWith("POST")) {
Order requestOrder = null;
try {
byte[] decodedRequest = base64DecodeApiGatewayEvent(input);
requestOrder = mapper.fromJson(new String(decodedRequest), Order.class);
Order savedOrder = astraClient.saveOrder(requestOrder);
LambdaResponse lambdaResponse = new LambdaResponse(savedOrder);
return lambdaResponse;
} catch (Exception e) {
logger.log("Could not save input '" + input + "' as order '" + requestOrder);
logger.log("Exception was: " + e.getLocalizedMessage());
return new LambdaResponse(
"{ \"message\": \"Order could not be saved.\" }",
SC_BAD_REQUEST);
}
} else {
return new LambdaResponse(
"{ \"message\": \"HTTP method is not supported.\" }",
SC_BAD_REQUEST);
}
}
private byte[] base64DecodeApiGatewayEvent(APIGatewayV2HTTPEvent input) {
byte[] decodedRequest;
if (input.getIsBase64Encoded()) {
decodedRequest = Base64.decodeBase64(input.getBody());
} else {
String body = input.getBody();
decodedRequest = body != null ? body.getBytes(UTF_8) : null;
}
return decodedRequest;
}
}
Now that we successfully implemented our Lambda handler, we will look into building and packaging it so it can be executed inside a GraalVM native runtime.
Build steps
The goal of our build pipeline will be to generate a Lambda runtime package since we have to provide the entire runtime to AWS Lambda if we want to use GraalVM native code.
We’re going to use Maven as our build tool, and we’ll combine the following Maven plugins and dependencies to generate the runtime package:
-
lambda-runtime-graalvm
(dependency) to generate the custom Lambda runtime. -
native-image-maven-plugin
(plugin) to generate the native GraalVM image. -
maven-assembly-plugin
(plugin) to package everything into a zip file. -
git-commit-id-plugin
(plugin) to tag the zip file with the commit hash.
Let’s dive into each component in detail.
Custom Lambda runtime
By including the dependency lambda-runtime-graalvm
, we’re adding the class com.formkiq.lambda.runtime.graalvm.LambdaRuntime
, which is the main class for the native image.
<dependency>
<groupId>com.formkiq</groupId>
<artifactId>lambda-runtime-graalvm</artifactId>
<version>${lambdaRuntime.version}</version>
</dependency>
It does everything a custom runtime needs to do, including the invocation of our Lambda handler function with the event payload. Please consult the Custom AWS Lambda runtimes documentation for more information on how to build custom runtimes from scratch.
Building the GraalVM native image
Next, we configure the native-image-maven-plugin
. The following listing contains the plugin definition. We’ll go over it in more detail in the upcoming paragraphs.
<plugin>
<groupId>org.graalvm.nativeimage</groupId>
<artifactId>native-image-maven-plugin</artifactId>
<version>${graalvm.version}</version>
<executions>
<execution>
<goals>
<goal>native-image</goal>
</goals>
<phase>package</phase>
</execution>
</executions>
<configuration>
<skip>false</skip>
<imageName>serverless-astra-graalvm</imageName>
<mainClass>com.formkiq.lambda.runtime.graalvm.LambdaRuntime</mainClass>
<buildArgs>
<buildArg>--no-fallback</buildArg>
<buildArg>--enable-url-protocols=http</buildArg>
<buildArg>-H:ReflectionConfigurationFiles=../src/main/resources/reflect.json</buildArg>
<buildArg>--no-server</buildArg>
</buildArgs>
</configuration>
</plugin>
In the executions
section, we include the native-image
goal as part of the package
phase, which will invoke via ./mvnw package
. In terms of plugin configuration, we’ll define an image name and the main class, which we pulled in via lambda-runtime-graalvm
in the previous section.
Once you build a native image, it only includes code that is reachable from the configured main class, which will break some dynamic features offered by the JVM, such as reflection and URL protocols. To make sure our application works nevertheless, we need to pass a couple of build arguments:
-
--no-fallback
makes sure we get a standalone image, or the build fails. -
--enable-url-protocols=http
enables HTTP support. -
-H:ReflectionConfigurationFiles=../src/main/resources/reflect.json
specifies the configuration file which contains all reflective accesses our application might perform. -
--no-server
tells the builder not to start a dedicated build server but instead build the image in the builder process.
For more information on native-image build arguments, please consult the GraalVM Native Image Options documentation. The next listing contains the contents of reflect.json
, which contains a bunch of data classes that we need to serialize and deserialize with Gson, as well as our Lambda handler class which the runtime needs to instantiate based on the qualified class name passed to the Lambda function.
[
{
"name": "com.github.codecentric.LambdaHandler",
"allPublicConstructors": true,
"allPublicMethods": true
},
{
"name": "com.github.codecentric.Order",
"allPublicConstructors": true,
"allDeclaredFields": true
},
{
"name": "com.github.codecentric.LambdaResponse",
"allPublicConstructors": true,
"allDeclaredFields": true
},
{
"name": "com.github.codecentric.OrderDocument",
"allPublicConstructors": true,
"allDeclaredFields": true
},
{
"name": "com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent",
"allPublicConstructors": true,
"allDeclaredFields": true
},
{
"name": "com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent$RequestContext",
"allPublicConstructors": true,
"allDeclaredFields": true
},
{
"name": "com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent$RequestContext$Http",
"allPublicConstructors": true,
"allDeclaredFields": true
}
]
Now that we can build our Lambda runtime as a GraalVM native image, we only need to package it into an archive which we can upload to AWS Lambda.
Zipping it up
Packaging the native image in a zip file will be done with the maven-assembly-plugin
. Analogous to the native-image plugin, we will execute the plugin goal as part of the package
phase.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>2.2-beta-5</version>
<configuration>
<finalName>${project.artifactId}-${git.commit.id.abbrev}</finalName>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>assembly.xml</descriptor>
</descriptors>
</configuration>
</execution>
</executions>
</plugin>
The file assembly.xml
contains the output file format (zip) and defines what we’re zipping.
<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.2 http://maven.apache.org/xsd/assembly-1.1.2.xsd">
<id>zip</id>
<includeBaseDirectory>false</includeBaseDirectory>
<formats>
<format>zip</format>
</formats>
<files>
<file>
<source>${project.build.directory}/${project.artifactId}</source>
<outputDirectory>/</outputDirectory>
</file>
<file>
<source>bin/bootstrap</source>
<outputDirectory>/</outputDirectory>
</file>
</files>
</assembly>
The zip file name will have the git commit appended to make it easier to distinguish different packages when we update our Lambda function. The variable ${git.commit.id.abbrev}
is defined by the git-commit-id-plugin
.
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
<version>4.0.4</version>
<executions>
<execution>
<goals>
<goal>revision</goal>
</goals>
<phase>validate</phase>
</execution>
</executions>
<configuration>
<dotGitDirectory>${project.basedir}/.git</dotGitDirectory>
</configuration>
</plugin>
Now running ./mvnw package
generates the Lambda zip file. The next section covers creating the Lambda function and API Gateway resources to deploy and wire everything together.
Infrastructure
In order to create our infrastructure, we are using Terraform. Terraform is also used to create our Astra DB instance using the DataStax Astra Provider, but we’ll not discuss it in this post. Please check out the blog post, Let’s Get Started With Terraform for Astra DB, for more information on the DataStax Astra Provider.
Lambda
The Lambda function resource and surrounding resources are created through the terraform-aws-modules/lambda/aws
module. The following snippet contains the lambda_function
module definition.
module "lambda_function" {
source = "terraform-aws-modules/lambda/aws"
version = "2.1.0"
function_name = var.project_name
handler = "com.github.codecentric.LambdaHandler::handleRequest"
runtime = "provided"
create_package = false
local_existing_package = "../target/serverless-astra-graalvm-${local.git-short-sha}.zip"
timeout = 30
tags = {
Name = var.project_name
Version = local.git-short-sha
}
environment_variables = {
ASTRA_URL = "https://${astra_database.main.id}-${astra_database.main.region}.apps.astra.datastax.com/api/rest"
ASTRA_TOKEN = var.astra_db_client_token
ASTRA_NAMESPACE = astra_database.main.keyspace
}
}
The important properties of the module are:
- Our native runtime image zip file.
- The handler function.
- Environment variables holding the credentials.
It’s good practice to manage the credentials in a secret store, such as AWS Secrets Manager. For simplicity’s sake, they are passed as Terraform variables here.
API Gateway
With the Lambda function in place, let’s create the API Gateway resources to call our function via HTTP. This requires an API (aws_apigatewayv2_api
), a stage (aws_apigatewayv2_stage
), an integration (aws_apigatewayv2_integration
), as well as two routes (aws_apigatewayv2_route
).
The API defines the protocol type HTTP:
resource "aws_apigatewayv2_api" "api-gateway" {
name = "serverless-graal-http-api"
protocol_type = "HTTP"
}
For this pet project, we will only need one stage:
resource "aws_apigatewayv2_stage" "dev-stage" {
api_id = aws_apigatewayv2_api.api-gateway.id
name = "$default"
auto_deploy = true
}
The integration allows our routes to invoke our Lambda function:
resource "aws_apigatewayv2_integration" "api-gateway" {
api_id = aws_apigatewayv2_api.api-gateway.id
integration_uri = module.lambda_function.lambda_function_invoke_arn
integration_type = "AWS_PROXY"
integration_method = "POST"
payload_format_version = "2.0"
}
We require two routes, one for order retrieval (api-gateway-get
) and one for order persistence (api-gateway-post
):
resource "aws_apigatewayv2_route" "api-gateway-get" {
api_id = aws_apigatewayv2_api.api-gateway.id
route_key = "GET /order/{orderId}"
target = "integrations/${aws_apigatewayv2_integration.api-gateway.id}"
}
resource "aws_apigatewayv2_route" "api-gateway-post" {
api_id = aws_apigatewayv2_api.api-gateway.id
route_key = "POST /order"
target = "integrations/${aws_apigatewayv2_integration.api-gateway.id}"
}
And that’s it! Now, after applying the Terraform plan, we can use our serverless order API.
Demo
We use the terraform apply
command to create the entire infrastructure, including the API gateway, the Lambda function, and the Astra database.
$ terraform apply --auto-approve
astra_database.main: Creating...
aws_apigatewayv2_api.api-gateway: Creating...
module.lambda_function.aws_iam_role.lambda[0]: Creating...
module.lambda_function.aws_cloudwatch_log_group.lambda[0]: Creating...
module.lambda_function.aws_iam_role.lambda[0]: Creation complete after 1s [id=serverless-astra-graalvm]
module.lambda_function.aws_cloudwatch_log_group.lambda[0]: Creation complete after 2s [id=/aws/lambda/serverless-astra-graalvm]
module.lambda_function.data.aws_iam_policy_document.logs[0]: Reading...
module.lambda_function.data.aws_iam_policy_document.logs[0]: Read complete after 0s [id=3519125711]
module.lambda_function.aws_iam_policy.logs[0]: Creating...
aws_apigatewayv2_api.api-gateway: Creation complete after 2s [id=fkxcduwzd1]
aws_apigatewayv2_stage.dev-stage: Creating...
module.lambda_function.aws_iam_policy.logs[0]: Creation complete after 0s [id=arn:aws:iam::***:policy/serverless-astra-graalvm-logs]
module.lambda_function.aws_iam_policy_attachment.logs[0]: Creating...
module.lambda_function.aws_iam_policy_attachment.logs[0]: Creation complete after 1s [id=serverless-astra-graalvm-logs]
aws_apigatewayv2_stage.dev-stage: Creation complete after 2s [id=$default]
astra_database.main: Still creating... [10s elapsed]
...
astra_database.main: Still creating... [1m10s elapsed]
astra_database.main: Creation complete after 1m19s [id=efae102e-0708-4233-a11f-b7e64422e221]
module.lambda_function.aws_lambda_function.this[0]: Creating...
module.lambda_function.aws_lambda_function.this[0]: Creation complete after 9s [id=serverless-astra-graalvm]
aws_apigatewayv2_integration.api-gateway: Creating...
aws_apigatewayv2_integration.api-gateway: Creation complete after 1s [id=qx9878r]
aws_apigatewayv2_route.api-gateway-get: Creating...
aws_apigatewayv2_route.api-gateway-post: Creating...
aws_apigatewayv2_route.api-gateway-post: Creation complete after 1s [id=x1u1rjm]
aws_apigatewayv2_route.api-gateway-get: Creation complete after 1s [id=3ceq7q3]
Apply complete! Resources: 11 added, 0 changed, 0 destroyed.
Outputs:
aws_api_gateway = "https://flsreywmp1.execute-api.eu-west-1.amazonaws.com"
Terraform plans the necessary changes and — due to the --autoapprove
flag — immediately takes action. After completing the Terraform command, all our components are ready to be used. The Outputs section shows us the URL of our API gateway endpoint. We can invoke our API by sending JSON queries via the curl command.
$ curl \
--header 'Content-Type: application/json' \
--data '{"productName": "googly eyes", "productQuantity": 3, "productPrice": 199}' \
https://9p8328wdcg.execute-api.eu-west-1.amazonaws.com/order
# {"orderId":"15249e2d-57c5-4fdd-a076-be64812b2739","productName":"googly eyes","productQuantity":3,"productPrice":199}
Our /order
endpoint accepts a JSON document containing order details. It persists the order in Astra DB and returns the persisted object, which now contains an orderId
. Now we can use curl
to retrieve the order again.
$ curl https://9p8328wdcg.execute-api.eu-west-1.amazonaws.com/order/15249e2d-57c5-4fdd-a076-be64812b2739
# {"orderId":"15249e2d-57c5-4fdd-a076-be64812b2739","productName":"googly eyes","productQuantity":3,"productPrice":199}
Conclusion
This concludes our two-part series of developing a backend using serverless technologies. In Part 1 we successfully implemented and tested an Astra Client that we can use inside our AWS Lambda function. Part 2 shows how you can implement the Lambda function and run it inside a GraalVM native image runtime.
You can look at the entire project over on Github, and if you have any questions or want to know more about this project, feel free to reach out to us at @FRosnerd and @raffael on Twitter.
Follow the DataStax Tech Blog for more developer stories. Check out our YouTube channel for tutorials, and follow DataStax Developers on Twitter for the latest news about our developer community.
Resources:
- Part 1 — How We Built a Serverless Backend Using GraalVM, AWS Lambda and Astra DB
- AWS Lambda Documentation
- Using AWS Lambda with Amazon API Gateway
- Astra DB
- GraalVM
- Apache Maven
- Custom AWS Lambda runtimes
- GraalVM Native Image Options
- Gson
- Terraform
- Let’s Get Started with Terraform for Astra DB
- Datastax
- Serverless
- Backend
- AWS
- Terraform
If you liked this post, you can support me on ko-fi.
Top comments (0)