Introduction Link to heading
AWS Lambda is a serverless computing service that lets you run code without provisioning or managing servers. Lambdas are very flexible because you can run them on a schedule, in response to events, or even as an API endpoint that responds to an HTTP request. In this post, we’ll look at how to create a Lambda function using Go and the AWS Cloud Development Kit (CDK) - with a focus on using CDK to speed up the development process and improve operational efficiency.
AWS Lambda Link to heading
I will try my best not to regurgitate the AWS Lambda documentation here. In a nutshell, the whole point of the Lambda service is to run code without having to worry about the underlying compute infrastructure; normally EC2 or Fargate. Lambdas can scale automatically, and you only pay for what you use.
If you’re wondering how and where your code is run, read more about Firecracker on this blog post by AWS.
All of this flexibility comes with trade-offs. Currently, Lambda executions are limited in time to 15 minutes, and the maximum memory you can allocate is 10 GB; and I say currently because these limits have been increased in the past and will most likely increase in the future. This is all to say that if you want to run a long-running process or a memory-intensive task, you will probably have a bad time. Even more so, if you want to run a stateful process, you will have to maintain state somewhere else, the function’s execution environment (scope) is virtually stateless and ephemeral. Lambdas are designed to run short-lived, stateless, scalable, and hopefully idempotent processes; you can see best practices here.
Lambda Runtimes Link to heading
Lambda supports a variety of runtimes, including Node.js, Python, Ruby, Java, .NET, and OS-only runtime. Excluding .NET, all the language-specific runtimes are for languages that are interpreted or run on a virtual machine, these are called managed runtimes.
For example, Java source code is first compiled into Java byte code, which is then interpreted by the Java Virtual Machine (JVM) which then translates the byte code into machine code instructions which depend on the CPU architecture; this is why there are different JVMs for different architectures. You can even customize some JVM settings like the memory allocation and the garbage collection strategy.
When you run a Java Lambda, the Lambda service spins up a container that contains the JVM and your code; then the JVM executes the Java byte code. The process is virtually the same for other languages like Python, Ruby, and Node.js; the Lambda service spins up a container that contains the interpreter and your code, and then the interpreter executes your code. This whole lifecycle is described here, and it’s important to understand if we are very concerned about cost and performance optimization.
What about ahead-of-time compiled languages like Go, C++ or Rust? These languages are compiled into machine code that can be executed directly by the CPU, which means that there is no need for an interpreter or a virtual machine. Because there’s less work to do when launching the runtime environment, the cold start time for Go and Rust Lambdas is much faster than for interpreted languages. For these languages you don’t use a managed runtime, but an OS-only runtime instead.
There’s an amazing ongoing benchmark set up by Maxime David where you can see the cold start, memory usage, and execution time for different runtimes and languages.
Choosing a Runtime Link to heading
As always, design decisions are all about trade-offs. Depending on your team’s proficiency, you might choose a language everyone is familiar with but at the cost of lower performance and higher costs for the service since this would allow you to save on developer time. On the other hand, you might choose a language that is more performant and cost-effective but at the cost of developer time and possibly maintenance.
Let’s assume our focus is on performance and cost-effectiveness. The options are Go, C++, or Rust. Let’s drop C++ out of the race right away because the White House told us to be good citizens and use only memory-safe languages. This leaves us with Go and Rust.
The biggest difference between Go and Rust is that Go is garbage collected and Rust is not. This means that Go has a runtime that manages memory for you, while Rust requires you to manage memory yourself (not by malloc
but by its own, beautiful, ownership model). The garbage collector in Go can introduce latency and overhead, which can affect performance. Rust, on the other hand, has no garbage collector, which means that you have more control over memory management and can write more efficient code. Also, both languages can be statically or dynamically linked, which can affect the size of the binary and the cold start time; although Go is statically linked by default.
AWS CDK Link to heading
The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework that allows you to define your infrastructure as code (IaC) and also helps you manage its lifecycle.
If you ever wanted to run a loop and create a bunch of buckets, queues, or whatever resource you can think of, you can do it with CDK. Even more powerful is to use CDK with an OOP language, where you can better define your service’s domain. Using the right OOP approach, your service can be more maintainable, scalable, and testable. At the end of the day, CDK is just a middle layer between you and the CloudFormation service, which is the service that actually creates the resources.
We will use the TypeScript CDK library to create a stack and this stack is the one where we will deploy our Go Lambda function.
Setting up the CDK Link to heading
Follow the instructions here to install the CDK CLI and create a new project. Once you have your project set up, you can start creating your stack.
You can see the final repository here
Creating the Lambda Function Link to heading
import { GoFunction } from '@aws-cdk/aws-lambda-go-alpha';
import * as cdk from 'aws-cdk-lib';
import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';
export class LambdaGoStack extends cdk.Stack {
private readonly goFunc: GoFunction;
private readonly ddbTable: Table;
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
this.ddbTable = new Table(this, 'HelloGoTable', {
partitionKey: {name: 'Name', type: AttributeType.STRING}
});
this.goFunc = new GoFunction(this, 'HelloGo', {
entry: 'go-lambda',
environment: {
'TABLE_NAME': this.ddbTable.tableName
}
});
this.ddbTable.grantReadWriteData(this.goFunc);
}
}
This code does the following:
- Creates a CloudFormation Stack.
- The stack has two things: a DynamoDB table and a Lambda function.
- The Lambda function is a GoFunction, which is a CDK construct that allows you to deploy a Go Lambda function using a Docker container for the build process.
- Then we give permissions to the Lambda function to read and write to the DynamoDB table.
Before we can deploy this, we need to define the Lambda function!
Writing the Lambda Function Link to heading
The Lambda function is a simple Go program that reads and writes to the DynamoDB table. The code is as follows:
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)
type MyEvent struct {
Name string `json:"name"`
}
type TableItem struct {
Name string
Timestamp string
}
func newTableItem(name string) TableItem {
return TableItem{
Name: name,
Timestamp: time.Now().Format(time.RFC3339),
}
}
func HandleRequest(ctx context.Context, event *MyEvent) (*string, error) {
if event == nil {
return nil, fmt.Errorf("received event is nil")
}
// we create a new session to the AWS SDK
sess := session.Must(session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
}))
// we create a new DynamoDB client
svc := dynamodb.New(sess)
// we create a new item to be inserted in the table
item := newTableItem(event.Name)
// we marshal the item into a map
av, err := dynamodbattribute.MarshalMap(item)
if err != nil {
log.Fatalf("Got error marshalling new item: %s", err)
}
// we construct the input request
input := &dynamodb.PutItemInput{
Item: av,
TableName: aws.String(os.Getenv("TABLE_NAME")),
}
// we put the item in the table
_, err = svc.PutItem(input)
if err != nil {
log.Fatalf("Got error calling PutItem: %s", err)
}
return aws.String(fmt.Sprintf("Hello %s!", event.Name)), nil
}
func main() {
lambda.Start(HandleRequest)
}
If you have questions about the directory structure, please refer to the repository.
Deploying Link to heading
To deploy the stack, run the following commands:
cdk deploy
That’s it! Now all this infrastructure is deployed to your account. You can delete it by running:
cdk destroy
If you want to make further changes to any component, you can do so by modifying the code and running cdk deploy
again.
Testing Link to heading
Now that the stack is deployed, you can test the Lambda function by running the following command:
aws lambda invoke --function-name <FunctionName> --payload '{"name": "Pepito"}' --cli-binary-format raw-in-base64-out /dev/stdout
And we can verify that the item was inserted in the DynamoDB table by running:
aws dynamodb scan --table-name <TableName>
Which returns:
{
"Items": [
{
"Timestamp": {
"S": "2024-09-29T00:40:29Z"
},
"Name": {
"S": "Pepito"
}
}
],
"Count": 1,
"ScannedCount": 1,
"ConsumedCapacity": null
}
Conclusion Link to heading
This was a very simple example. We left out important details, particularly about the CI/CD cycle. The good thing is that there’s a plethora of tools that can help you with that, like deploying your CDK stack with GitHub Actions or AWS CodePipeline. As long as there’s a mechanism to run the cdk deploy
command, you can use any tool you like. Next time, we’ll do the same exercise but with Rust 🦀.