Running multiple Golang AWS lambda functions on ARM64 with serverless.com

Running multiple Golang AWS lambda functions on ARM64 with serverless.com

You are looking to use serverless.com for Golang lambda deployments or want to switch to arm64 images? This article will guide you through the process and point out some limitations and things to consider.

TL;DR;

Using ARM64 runtimes with serverless.com for Golang lambdas on AWS can provide significant performance and cost advantages. While there may be some limitations and considerations to keep in mind, such as the possible deprecation of the go1.x runtime and the need to provide bootstrap files for the provided.al2 runtime, the benefits of switching to ARM64 are worth considering. The code for this guide is available in this GitHub repository: https://github.com/matthiasbruns/golang-sls-provided.al2-arm64.


Why should you switch to ARM64?

Performance and cost

ARM64 runtimes are faster and cheaper than x86_64 runtimes on AWS lambda. ARM processors use less power and generate less heat, which lowers the cost of running them. Additionally, ARM64 runtimes are optimized for high-performance computing and can lead to faster execution times for applications. With recent advancements in ARM64 technology, it's becoming an increasingly popular choice for serverless computing.

Advantages of using arm64 architecture

Lambda functions that use arm64 architecture (AWS Graviton2 processor) can achieve significantly better price and performance than the equivalent function running on x86_64 architecture. Consider using arm64 for compute-intensive applications such as high-performance computing, video encoding, and simulation workloads.

The Graviton2 CPU uses the Neoverse N1 core and supports Armv8.2 (including CRC and crypto extensions) plus several other architectural extensions.

Graviton2 reduces memory read time by providing a larger L2 cache per vCPU, which improves the latency performance of web and mobile backends, microservices, and data processing systems. Graviton2 also provides improved encryption performance and supports instruction sets that improve the latency of CPU-based machine learning inference.

For more information about AWS Graviton2, see AWS Graviton Processor.

💡 Taken directly from AWS https://docs.aws.amazon.com/lambda/latest/dg/foundation-arch.html#foundation-arch-adv

Possible deprecation of go1.x runtime

https://aws.amazon.com/de/blogs/compute/migrating-aws-lambda-functions-to-al2/

AWS will drop the go1.x runtime support for lambda end of June according to a migration blog post on their blog. They offer an official way to migrate to the new provided.al2 runtime using SAM templates. But what, if you use serverless.com?


How to migrate to ARM64?

To simulate a migration, we will build a sample application with two lambda endpoints using the old go1.x runtime. After that, I will guide you through the migration process.

Bootstrapping serverless.com with Golang using x86

You can find the bootstrapped project here: https://github.com/matthiasbruns/golang-sls-provided.al2-arm64/tree/chapter/bootstrapping.

There is no real complexity in the project itself, since this does not impact the sls setup.

Project Layout

The project layout looks like this

  • serverless: files created by serverless after running sls deploy

  • build: binaries built for serverless to deploy to AWS lambda

  • cmd: holds the main.go files which will be compiles into binaries

  • go.mod: go mod file which holds dependencies

  • Makefile: builds the binaries - you can also use Taskfile as an alternative

  • serverless.yml: sls configuration file

The Code

The code is pretty straightforward and assumes that the lambda will be triggered by an AWS API Gateway2 call.

// cmd/lambda/hello/main.go

package main

import (
    "context"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

func main() {
    // we are simulating a lambda behind an ApiGatewayV2
    lambda.Start(handler)
}

func handler(ctx context.Context, request events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
    return events.APIGatewayV2HTTPResponse{
        StatusCode: 200,
        Body:       "Hello",
    }, nil
}

This code starts a lambda handler, which always returns http-code 200 with “Hello” in the body. The other main file in the world package simply returns "World" instead of "Hello"

// cmd/lambda/world/main.go

package main

import (
    "context"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

func main() {
    // we are simulating a lambda behind an ApiGatewayV2
    lambda.Start(handler)
}

func handler(ctx context.Context, request events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
    return events.APIGatewayV2HTTPResponse{
        StatusCode: 200,
        Body:       "World",
    }, nil
}

The Makefile for building

The make file is pretty simple. It builds x86 binaries from both hello and world code branches and stores the output into the build/lambda folder. You can run the make file by calling make build.

build:
    echo "Building lambda binaries"
    env GOOS=linux GOARCH=amd64 go build -o build/lambda/hello cmd/lambda/hello/main.go
    env GOOS=linux GOARCH=amd64 go build -o build/lambda/world cmd/lambda/world/main.go

💡 For the sake of this example, I omitted build flags that speed up lambda cold starts.

The serverless configuration file

# serverless.yml

service: golang-go1x-x86

frameworkVersion: "3"
configValidationMode: error

provider:
  name: aws
  runtime: go1.x
  architecture: x86_64
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'eu-central-1'}
  httpApi:
    cors: true
    name: ${self:service}-${self:provider.stage}

functions:
  hello:
    name: hello
    handler: build/lambda/hello
    events:
      - httpApi:
          path: /x86/hello
          method: get
  world:
    name: world
    handler: build/lambda/world
    events:
      - httpApi:
          path: /x86/world
          method: get

The serverless file contains the following elements that are important for this example:

  • service: name of the service, which will be used to name the Cloudformation stack

  • provider: where sls deployed the stack to - we use AWS in this example. It also defines shared definitions like runtime and architecture, which will be used by the lambda functions.

  • functions: definition of the lambdas and their triggers. In this case, we use ApiGatewayV2 triggers, indicated by httpApi. We also define which binaries to use by settings the handler to the binary path relative to the serverless.yml file.

Deploying the stack

To deploy the stack, you need an AWS account as well as CLI credentials in your path. I assume you have this setup already.

You only have to run sls deploy and serverless.com will take care of everything you need.

After successfully deploying your stack, you can call the ApiGateway endpoints. They will be written to your console by sls. Since they are GET endpoints, you can simply open them with your browser.

Let’s also check how the lambda looks on AWS:


Migrating to Arm64 with bootstrap files

After setting up the x86 project, we can begin to migrate to ARM64. To do this, we need to provide a so called bootstrap files for the provided.al2 runtime. According to AWS docs, we cannot name this file differently and it needs to be in the root of the ZIP-file we upload to be used in the lambda. You can check the code here: https://github.com/matthiasbruns/golang-sls-provided.al2-arm64/tree/chapter/migrate

Using a custom runtime

To use a custom runtime, set your function's runtime to provided.al2. The runtime can be included in your function's deployment package, or in a layer.

Example function.zip
.
├── bootstrap
├── function.sh

If there's a file named bootstrap in your deployment package, Lambda runs that file. If not, Lambda looks for a runtime in the function's layers. If the bootstrap file isn't found or isn't executable, your function returns an error upon invocation.

💡 Taken from AWS docs https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html

So let’s do it!

Updating Makefile and serverless.yml to run on ARM64

To migrate from x86 to arm64, we only need to change the build files. No code changes are required.

The Makefile for building arm64 Golang binaries

Let’s update the binary compilation first. You need to change GOARCH from amd64 to arm64 and rename the output files to bootstrap.

We also need to add a new task called zip, which zips each binary in a separate archive, since we need to upload each binary on it’s own through serverless to prevent naming conflicts in the .zip files.

build:
    echo "Building lambda binaries"
    env GOOS=linux GOARCH=arm64 go build -o build/lambda/hello/bootstrap cmd/lambda/hello/main.go
    env GOOS=linux GOARCH=arm64 go build -o build/lambda/world/bootstrap cmd/lambda/world/main.go

zip:
    zip -j build/lambda/hello.zip build/lambda/hello/bootstrap
    zip -j build/lambda/world.zip build/lambda/world/bootstrap

The serverless configuration file for provided.al2

Now for the serverless.yml, we have to change two things:

# serverless.yml

service: golang-provided-al2-arm64

frameworkVersion: "3"
configValidationMode: error

provider:
  name: aws
  runtime: provided.al2 # <- change from go1.x to provided.al2
  architecture: arm64   # <- change from x86_64 to arm64
  stage: ${opt:stage, 'dev'}
  region: ${opt:region, 'eu-central-1'}
  httpApi:
    cors: true
    name: ${self:service}-${self:provider.stage}

package:
  individually: true # <- package each function individually, to prevent file name conflicts

functions:
  hello:
    name: hello-arm64
    handler: bootstrap # <- the handler name must be bootstrap and in the root of the zip
    package:
      artifact: build/lambda/hello.zip # override the default artifact handling to use the built zip
    events:
      - httpApi:
          path: /arm64/hello
          method: get
  world:
    name: world-arm64
    handler: bootstrap # <- the handler name must be bootstrap and in the root of the zip
    package:
      artifact: build/lambda/world.zip # override the default artifact handling to use the built zip
    events:
      - httpApi:
          path: /arm64/world
          method: get

Let’s go through the changes.

First, we change runtime and architecture to the new target settings..

We also need to change how sls packages the .zip files. By default, it takes the whole project and puts it into one zip and uploads this to AWS, including all binaries built with Makefile. Since we need to provide a boostrap file in the root of the zip, we cannot use this packaging method anymore. Each function is not packaged individually, which also leads to smaller .zip archived and faster cold-starts.

The functions also to change a bit. In a nutshell, we change the handler to point to the new bootstrap file in the root of the .zip. Since we take over packaging, we have to point what .zip sls should upload to AWS. This is done in the functions package definition.

If we would not have done the changes, the deployment would have worked, but the lambdas would have crashed with some error like this:

Error: Couldn't find valid bootstrap(s): [/var/task/bootstrap /opt/bootstrap]

Deploy and test

💡 Recompile and zip the binaries with rm -rf build && make build && make zip, otherwise you will deploy x86 binaries to your arm64 lambdas!

After applying those two changes, we can redeploy the stack with sls deploy.

Let’s try out our new arm64 runtimes!

Looking good. Let’s also check the deployed lambdas on AWS

As you can see, we are now running on Linux 2 with the bootstrap handler and arm64.


Conclusion

In conclusion, switching to ARM64 can provide significant performance and cost advantages for running Golang lambdas on AWS. While there may be some limitations and considerations to keep in mind, such as the possible deprecation of the go1.x runtime and the need to provide bootstrap files for the provided.al2 runtime, the benefits of switching to ARM64 are worth considering. With the help of serverless.com, migrating to ARM64 can be a relatively straightforward process.

You can find the whole code in this GitHub repository: https://github.com/matthiasbruns/golang-sls-provided.al2-arm64

Did you find this article valuable?

Support Matthias Bruns by becoming a sponsor. Any amount is appreciated!