Choose a runtime and implement CRUD (DynamoDb)AWS lambda

Kostiantyn Ivanov
10 min readSep 9, 2023

--

Use case:

In our use case we have a simple CRUD service, that should work with a Post model:

Post {
id: string,
value: string
}

this service is a part of the system that doesn’t have a low latency requirements and it’s methods are used in a different places of the system (so it’s not going to became a part of some other service). We want to build it as simple as possible and don’t want to pay for it too much.

For this purpose we decided to use combination of AWS API Gateway + AWS Lambda + DynamoDB.

What is the AWS lambda?

AWS Lambda is a serverless computing service offered by Amazon Web Services (AWS). It allows you to run code in response to events and triggers without the need to provision or manage servers.

Advantages:

  • Highly scalable (and this is covered by a cloud provider)
  • Cost-effective (meaning you pay for it only when you use it)
  • May be implemented using a pretty low resources (that will affect costs positively)

Disadvantages:

  • Have a cold start (that impact on the latency). And this cold start latency will be increasing while your service is growing and starts slower.

AWS lambda runtimes

AWS Lambda runtimes are environments in which your AWS Lambda functions execute. Each runtime is associated with a specific programming language, and AWS manages the runtime environment, allowing you to focus solely on writing your function code.

Here is the list of available runtimes (actual for 2023/09/09):

  1. Node.js: up to 18.x.
  2. Python: up to 3.11.
  3. Java: up to 17.
  4. C#: up to .NET 6.
  5. Ruby: up to 3.2
  6. Go: Go 1.x.
  7. Custom Runtimes: AWS Lambda also provides the ability to create custom runtimes using the AWS Lambda Runtime API. This allows you to run code in languages that are not officially supported by AWS Lambda. Or use java native images.

Choose the runtime

Since we don’t have a restrictions about the language we will use (the main expertise is in a java but java native images is not something we are going to support right now) we made our decision based on the next criteria:

  1. Our runtime should have as small start-time as possible
  2. It should consume as less RAM as possible

Here is a comparation of “hello world” AWS Lambda on each of the runtimes:

Duration:

Max. memory used:

Initialization duration:

Based on this these criteria we choose Go 1.x as a runtime (it has a lowest memory consumption and one of the best duration).

Service implementation

First thing first we are implementing the lambda service with our CRUD logic.

Model

package model

type Item struct {
ID string `json:"id"`
Value string `json:"value"`
}

Repository

package repository

import (
"errors"
"fmt"
"hello-world-lambda/app/model"
"strings"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
)

// Define the DynamoDB table name and AWS region.
const (
tableName = "Posts"
region = "us-east-2"
)

var dynamoDBClient *dynamodb.DynamoDB

func init() {
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
})

if err != nil {
panic(err)
}

dynamoDBClient = dynamodb.New(sess)
}

func PutItem(item model.Item) (string, error) {
input := &dynamodb.PutItemInput{
TableName: aws.String(tableName),
Item: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(item.ID),
},
"value": {
S: aws.String(item.Value),
},
},
}

_, err := dynamoDBClient.PutItem(input)

return item.ID, err
}

func GetItem(id string) (string, error) {
input := &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(id),
},
},
}

result, err := dynamoDBClient.GetItem(input)
if err != nil {
return "", err
}

if result.Item == nil {
return "", errors.New("Item not found")
}

return *result.Item["value"].S, nil
}

func DeleteItem(id string) (string, error) {
input := &dynamodb.DeleteItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"id": {
S: aws.String(id),
},
},
}

_, err := dynamoDBClient.DeleteItem(input)

return "", err
}

func GetAllItems() (string, error) {
input := &dynamodb.ScanInput{
TableName: aws.String(tableName),
}

result, err := dynamoDBClient.Scan(input)
if err != nil {
return "[]", err
}

var sb strings.Builder
sb.WriteString("[")

for i, item := range result.Items {
itemJSON := fmt.Sprintf(`{"%s": "%s"}`, *item["id"].S, *item["value"].S)

sb.WriteString(itemJSON)

if i < len(result.Items)-1 {
sb.WriteString(",")
}
}

sb.WriteString("]")

return sb.String(), nil
}

There are a somple CRUD operations on “Posts” DynamoDB table. If you are going to implement it — please don’t forget to change a region.

The on of the weird things you can find it — is a json building. It was made in this manner for simplicity and in the real project we would go models and all the possible mapping would go into a service layer.

Router

package router

import (
"errors"
"hello-world-lambda/app/model"
"hello-world-lambda/app/repository"
)

func Route(item model.Item, httpMethod string, hasNoPathParameters bool) (string, error) {
switch httpMethod {
case "POST":
return repository.PutItem(item)
case "PUT":
return repository.PutItem(item)
case "GET":
if hasNoPathParameters {
return repository.GetAllItems()
} else {
return repository.GetItem(item.ID)
}
case "DELETE":
return repository.DeleteItem(item.ID)
default:
return "", errors.New("Unexpected method")
}
}

A simple implementation of router — we just called needed repository method based on our httpRequest parameters.

Main

package main

import (
"context"
"encoding/json"
"hello-world-lambda/app/model"
"hello-world-lambda/router"

"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
item, error := parseItem(request)
if error != nil {
return events.APIGatewayProxyResponse{
StatusCode: 400,
Body: error.Error(),
}, nil
}

response, error := router.Route(item, request.HTTPMethod, len(request.PathParameters) == 0)

if error != nil {
return events.APIGatewayProxyResponse{
StatusCode: 400,
Body: error.Error(),
}, nil
}

return events.APIGatewayProxyResponse{
StatusCode: 200,
Body: response,
}, nil
}

func parseItem(request events.APIGatewayProxyRequest) (model.Item, error) {
postId := request.PathParameters["id"]

if request.HTTPMethod == "GET" {
if postId != "" {
return model.Item{ID: postId}, nil
} else {
return model.Item{}, nil
}
}

var item model.Item
if err := json.Unmarshal([]byte(request.Body), &item); err != nil {
return model.Item{}, err
}
return item, nil
}

func main() {
lambda.Start(handler)
}

Here we handle the AWSLambda event, unmarshal it into our model and call the router.

And… this is it actually. Our service code is ready.

Build a service

To build a service we need to run only one command:

go build main.go

but before build please check your GOOS and GOARH env. variables using

go env

to work on AWS the should have the next values:

GOARCH=amd64
GOOS=linux

in another case you will face not really self-descriptive runtime errors

After the building of the main executive file — archive it into a zip archive and we are going to deploy it.

Create AWS Lambda using AWS console

Creation

We choose to create our function from scratch, name it as “posts” and choose our Go 1.x runtime.

Resource configuration

We changed the memory to the 128MB (a minimal available choice) since our priority is low costs and memory consumption. We understand the tradeoffs between latency and costs.

Permissions

We added the full DynamoDB permissions for our AWS lambda. In your case a permissions profile may be more conservative.

Upload the service code

Change the handler file

The default handler file called “hello”. In uour case it should be renamed to “main”:

After we uploaded our function code and change the handler — the integration between AWS Lambda and DynamoDB is ready to test.

Test integration with DynamoDB

In our APIGatewayProxyRequest structure we can find the filed we will rely on to choose a route. In our test case we will use GET method just ro retrieve all the data from DynamoDB table

We can see the response from our lambda (in the case if we have data in our table). The AWS Service consumed 45 MB of memory for this call. If in the real scenario we are going to use the same memory configuration — we should care about limiting of data output. In other case we will face out of memory errors.

API Gateway integration

We create an open HTTP API. In the real scenario we would suggest to use authenticator.

Configuration

Opening the API Gateway instance we can find, that the default route for a base resource was already created:

The only one thing left is to add the “get one” route:

Now let’s attach trigger into a new endpoint:

Well done

That’s it, our CRUD service is ready and can be called using direct url:

Costs calculation

AWS Lambda:

Amount of memory allocated: 128 MB x 0.0009765625 GB in a MB = 0.125 GB
Amount of ephemeral storage allocated: 512 MB x 0.0009765625 GB in a MB = 0.5 GB
Pricing calculations
10,000 requests x 1,000 ms x 0.001 ms to sec conversion factor = 10,000.00 total compute (seconds)
0.125 GB x 10,000.00 seconds = 1,250.00 total compute (GB-s)
1,250.00 GB-s — 400000 free tier GB-s = -398,750.00 GB-s
Max (-398750.00 GB-s, 0 ) = 0.00 total billable GB-s
Tiered price for: 0.00 GB-s
Total tier cost = 0.0000 USD (monthly compute charges)
10,000 requests — 1000000 free tier requests = -990,000 monthly billable requests
Max (-990000 monthly billable requests, 0 ) = 0.00 total monthly billable requests
0.50 GB — 0.5 GB (no additional charge) = 0.00 GB billable ephemeral storage per function
Lambda costs — With Free Tier (monthly): 0.00 USD

DynamoDB:

100 GB x 0.25 USD = 25.00 USD (Data storage cost)

DynamoDB data storage cost (monthly): 25.00 USD

2 KB average item size / 1 KB = 2.00 unrounded write request units needed per item

RoundUp (2.000000000) = 2 write request units needed per item

5,000 number of writes x 1 standard portion x 1 write request units for standard writes x 2 write request units needed per item = 10,000.00 write request units for standard writes

5,000 number of writes x 0 transactional portion x 2 write request units for transactional writes x 2 write request units needed per item = 0.00 write request units for transactional writes

10,000.00 write request units for standard writes + 0.00 write request units for transactional writes = 10,000.00 total write request units

10,000.00 total write request units x 0.00000125 USD = 0.01 USD write request cost

Monthly write cost (monthly): 0.01 USD

  • Eventually consistent percentage: 100 / 100 = 1
  • Strongly consistent percentage: 0 / 100 = 0
  • Transactional percentage: 0 / 100 = 0

Pricing calculations

2 KB average item size / 4 KB = 0.50 unrounded read request units needed per item

RoundUp (0.500000000) = 1 read request units needed per item

5,000 number of reads x 1 eventually consistent portion x 0.5 read request units for eventually consistent reads x 1 read request units needed per item = 2,500.00 read request units for eventually consistent reads

5,000 number of reads x 0 strongly consistent portion x 1 read request units for strongly consistent reads x 1 read request units needed per item = 0.00 read request units for strongly consistent reads

5,000 number of reads x 0 transactional portion x 2 read request units for transactional reads x 1 read request units needed per item = 0.00 read request units for transactional reads

2,500.00 read request units for eventually consistent reads + 0.00 read request units for strongly consistent reads + 0.00 read request units for transactional reads = 2,500.00 total read request units

2,500.00 total read request units x 0.00000025 USD = 0.00 USD read request cost

Monthly read cost (monthly): 0.00 USD

API Gateway:

100 KB per request / 512 KB request increment = 0.1953125 request(s)

RoundUp (0.1953125) = 1 billable request(s)

10,000 requests per month x 1 unit multiplier x 1 billable request(s) = 10,000 total billable request(s)

Tiered price for: 10000 requests

10000 requests x 0.0000010000 USD = 0.01 USD

Total tier cost = 0.0100 USD (HTTP API requests)

HTTP API request cost (monthly): 0.01 USD

Thus, for the service like this we will pay $300 per year.

Summary

Lambda service with a low memory consuming runtimes could be a great choice for some small and simple systems (or part of the systems) without high requirements to latency. Using them we can save our time (for development and support) and money.

--

--