guide/

directory
v0.0.0-...-505e376 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 16, 2022 License: MIT

README

A guide to AWS Lambda for Go developers - Part 1

This multi-part tutorial series aims to give you a feeling of Lambda function programming in Go. It assumes that you don't have previous knowledge of AWS or Lambda. 

This is the first part where we will make a few small steps to start you in this new world. For the difference of many other examples, we will not use AWS Console or a higher-level serverless tool. We will create one dummy Lambda function and then execute it. We will do that in two ways: one using AWS command-line interface, and in the other, we will use Terraform to create AWS resources. It's interesting to see two different approaches. One is imperative (AWS CLI), where we specify each step, and the other is declarative (Terraform), where we define the desired end state of the infrastructure.

Toolset

For those who are on macOS and are using Homebrew getting started required tools is a one-liner:

brew bundle

in the root of this repo. Of course, you first need to clone this repo and position yourself int the guide folder.

For other OS-es, you will need to install Go, AWS CLI, jq and terraform.

AWS Credentials

You will need an AWS account and access keys for a user in that account.

After you have the access key, set it as environment variables in the shell:

export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
export AWS_DEFAULT_REGION=us-east-1

Change these demo values with your access key, secret key and AWS region closest to you. We will use Graviton2 (ARM) powered Lambda functions, so you need to choose one of the regions where it is supported:

US East (N. Virginia), US East (Ohio), US West (Oregon), Europe (Frankfurt), Europe (Ireland), EU (London), Asia Pacific (Mumbai), Asia Pacific (Singapore), Asia Pacific (Sydney), Asia Pacific (Tokyo).

To test connectivity to the AWS account with CLI run:

aws sts get-caller-identity --output table --no-cli-pager

This should return Account, Arn and, UserId of the AWS user for which you set access credentials. If this command succeeds, you are ready to go.

About Go code

In the handler folder is a dummy Lambda function handler. That is an unmodified copy of the code from AWS docs. Lambda package provides all the plumbing with Lambda execution runtime. It uses reflection to analyze provided handler, performs JSON deserialization of the payload and serialization of the response.

Implemented handler (HandleRequest function in the example) must satisfy these rules.

AWS CLI

Create Lambda function

Let's create first create a lambda function, and then we will look into the process. Position yourself into the handler folder and there run publish.sh from the scripts folder:

cd handler
../../scripts/publish.sh

The expected output is something like this:

=> build
=> create deployment package
  adding: bootstrap (deflated 49%)
=> create new role
{
    "Role": {
        "Path": "/",
        "RoleName": "go-handler-example-role",
        "RoleId": "AROAQYPA52WDGY247IQCE",
        "Arn": "arn:aws:iam::052548195718:role/go-handler-example-role",
        "CreateDate": "2022-01-19T14:43:33+00:00",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "lambda.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        }
    }
}
=> create Lambda function
{
    "FunctionName": "go-handler-example",
    "FunctionArn": "arn:aws:lambda:eu-central-1:052548195718:function:go-handler-example",
    "Runtime": "provided.al2",
    "Role": "arn:aws:iam::052548195718:role/go-handler-example-role",
    "Handler": "provided",
    "CodeSize": 4087476,
    "Description": "",
    "Timeout": 3,
    "MemorySize": 128,
    "LastModified": "2022-01-19T14:43:43.568+0000",
    "CodeSha256": "PRgB5sSH1C+B9YrsAquFvpyWgSfHvwBaOK33564ZZ6k=",
    "Version": "$LATEST",
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "d7e38f6b-ff8a-4873-a259-b6350b149b3d",
    "State": "Pending",
    "StateReason": "The function is being created.",
    "StateReasonCode": "Creating",
    "PackageType": "Zip",
    "Architectures": [
        "arm64"
    ]
}

Let's look inside the script and expain what is happening. This is publish.sh script:

 1 #!/usr/bin/env bash -e
 2 
 3 # read function name from first argument or use default
 4 function_name="${1:-go-handler-example}"
 5 
 6 # get folder of the this script
 7 scripts=$(dirname "$0")
 8 
 9 # run build script
10 $scripts/build.sh ${@:2}
11 
12 # check if the function already exists
13 if $(aws lambda get-function --function-name $function_name > /dev/null 2>&1); then
14     echo "=> update existing function"
15     aws lambda update-function-code \
16         --no-cli-pager \
17         --function-name "$function_name" \
18         --zip-file fileb://function.zip
19 else
20     # create new function
21     $scripts/create_function.sh $function_name
22 fi
23 
24 # delete artifacts
25 rm function.zip bootstrap

I don't assume any previous knowledge of shell scripting, so we will look into this script line by line. The script accepts the Lambda function name as the first parameter; if not supplied, go-handler-example will be used as default. Line 4 is where this happens; it uses the first argument or default for setting variable function_name. If you don't want the default function name run the script like ../../scripts/publish.sh my-lambda-function-name.
Line 7 gets the folder where publish.sh is located. We will call the other scripts (deploy.sh, create_function.sh) from this one so we grab that path and store it into scripts variable.
In line 10, we call build.sh, which will prepare Lambda function deployment package. Let's look into build.sh:

1 #!/usr/bin/env bash -e
2 
3 echo "=> build"
4 GOOS=linux GOARCH=arm64 go build -o bootstrap
5 
6 echo "=> create deployment package"
7 zip function.zip bootstrap $@

Line 4 is go build command. We are building for Linux arm64 platform. Lambda functions can be run on either Intel on AWS Graviton2 processors. Use Graviton to get lower price and better performance unless some requirements pull you back.
The resulting binary is named bootstrap. That is a requirement of the Lambda runtime provided.al2, which we will use for building the Lambda function. That runtime is a tiny Linux instance based on Amazon Linux 2; it will execute the bootstrap binary when started. Again, that bootstrap name is a requirement of the provided.al2 runtime.

Line 7 creates function.zip file with the bootstrap file in it. That zip file is the Lambda deployment package accepted by CLI commands, AWS Console or any other tool from which you can create the Lambda function. $@ at the end of the zip command is here to enable you to add any other files to the package. So you can, for example, with ../../scripts/build.sh config.yml add a config file to the package. That file will be available when running the Lambda function in the same folder as the binary.

After the build phase, we have function.zip file in the handler folder. So let's return to the publish script.

Line 13 checks whether the Lambda function with the name from variable function_name already exists. aws lambda get-function --function-name $function_name returns function configuration, but here we are checking only the result. Whether it was successful or not. If it was successful that function already exists, and we will just update the function code. If not, we will call another script create_function.sh. Let's examine the process of creating a new Lambda function:

 1 #!/usr/bin/env bash -e
 2 
 3 function_name="${1:-go-handler-example}"
 4 
 5 echo "=> create new role"
 6 role_name="$function_name-role"
 7 aws iam create-role \
 8     --role-name "$role_name" \
 9     --no-cli-pager \
10     --assume-role-policy-document '{"Version": "2012-10-17","Statement": [{ "Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}'
11 
12 # read role arn
13 role_arn=$(aws iam get-role --role-name "$role_name" | jq .Role.Arn -r)
14 aws iam attach-role-policy \
15     --no-cli-pager \
16     --role-name "$role_name" \
17     --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
18 
19 aws iam wait role-exists --role-name "$role_name"
20 
21 echo "=> create Lambda function"
22 # run with retries of few seconds to give time role to become visible
23 for i in 5 1 1 1 1 1; do
24     sleep "$i" # waiting for role to be available
25     aws lambda create-function \
26         --function-name "$function_name" \
27         --runtime provided.al2 \
28         --zip-file fileb://function.zip \
29         --role "$role_arn" \
30         --handler provided \
31         --architectures "arm64" \
32         --no-cli-pager && break
33 done

From line 5 to line 19 is the process of creating the Lambda execution role. We need that role in line 29 for actually creating the Lambda function. I will skip details of the IAM, policy, role story... It is necessary to give your function permission on other AWS resources, but that is a separate topic. Just note that in line 17, we give our Lambda function AWSLambdaBasicExecutionRole, which provides that function with permission to upload logs to Cloudwatch and nothing more.

Loop in lines 23, 24 and && break part at the end of the create-function command (line 25) gives us a few retries for create-function command. When we create a new role, it is not immediately visible by new Lambda functions, so create-function command can fail with an error: An error occurred (InvalidParameterValueException) when calling the CreateFunction operation: The role defined for the function cannot be assumed by Lambda. The script waits for 5 seconds before the first try and then makes a few more tries each after 1 second.

Line 25 is the actual create-function AWS CLI command. We provide the function name (line 26), runtime on which Lambda function will be built (line 27). This is a Go application, so we use provided.al2 runtime. There are runtimes for other languages. In line 28, we give our function.zip as content for the new Lambda. Another useful option is instead of using a local file to specify S3 location where the package is located. In line 31, we specify architecture for the function (arm64 or x86_64).

Invoke Lambda function

Run the invoke.sh script from the scripts folder:

../../scripts/invoke.sh

The expected output is:

{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
"Hello Foo!"

And the invoke.sh is:

 1 #!/usr/bin/env bash -e
 2 
 3 function_name="${1:-go-handler-example}"
 4 
 5 aws lambda invoke \
 6   --function-name "$function_name" \
 7   --no-cli-pager \
 8   --cli-binary-format raw-in-base64-out \
 9   --payload '{"name":"Foo"}' \
10   response.json && cat response.json
11 
12 rm response.json

Script uses lambda invoke CLI command. Line 6 specifies a function and line 9 JSON payload. In this case we are sending {"name":"Foo"} JSON. This command writes a response to the file. So we provide a file, show response content cat response.json, and remove that file at the end of the script.

You can play by changing the payload attribute to get different results.

View Lambda function logs

Run:

../../scripts/logs.sh

The expected output is something like:

last stream name: 2022/01/20/[$LATEST]7b576140275c4b4d9aee7288717766c3
1642696161536   START RequestId: 65da5fff-aea9-4d67-8366-499d3942adf7 Version: $LATEST\n
1642696161537   END RequestId: 65da5fff-aea9-4d67-8366-499d3942adf7\n
1642696161537   REPORT RequestId: 65da5fff-aea9-4d67-8366-499d3942adf7\tDuration: 1.14 ms\tBilled Duration: 46 ms\tMemory Size: 128 MB\tMax Memory Used: 17 MB\tInit Duration: 44.06 ms\t\n

logs.sh script:

 1 #!/usr/bin/env bash -e
 2 
 3 function_name="${1:-go-handler-example}"
 4 
 5 # get the name of the last log stream
 6 stream_name=$(aws logs describe-log-streams --log-group-name /aws/lambda/$function_name | jq ".logStreams[].logStreamName" -r | tail -n 1)
 7 
 8 echo "last stream name: $stream_name"
 9 # show logs as table
10 aws logs get-log-events \
11     --log-group-name /aws/lambda/$function_name \
12     --log-stream-name "$stream_name" \
13     | jq ".events[] | [.timestamp, .message] | @tsv" -r

Here we are showing lambda function logs from the AWS Cloudwatch service. By default, the Lambda function sends logs to the Cloudwatch service. Cloudwatch is organized into log groups and log streams. Each lambda function gets a log group named /aws/lambda/[function-name]. Into that group, each Lambda initialization creates a new log stream. Function initialization happens on first invoke after that execution environment lives for some time.
This script finds the last stream name for our function log group and then lists logs in that stream. Line 6 executes describe-log-streams which list all log streams in the log group in JSON array. We use jq tool here to select only logStreamName attribute, tail -n 1 returns the last line from the list of all streams. Now when we have stream_name we can call get-log-events for that stream in line 10. Again we use jq to reformat JSON into the table.

These logs show only Lambda execution environment stats. Put some log.Printf(...) lines into the handler Go code, and you will find them in the logs. Any output from the handler binary will be available in Cloudwatch logs.

Cleanup

To remove Lambda function and other created resources (role and logs) in the AWS account, run the cleanup script:

../../scripts/cleanup.sh

Terraform

Create infrastructure

Again position yourself into the handler folder. Use build.sh to create Lambda deployment package function.zip there:

../../scripts/build.sh

Then switch to the terraform folder:

cd ..
cd terraform

Be sure to have set AWS_DEFAULT_REGION environment variable before running terraform. For example:

export AWS_REGION=eu-central-1

Then execute terrafrom init and apply commands:

terraform init
terraform apply --auto-approve

Execute function

../../scripts/invoke.sh $(terraform output --raw function_name)

Here, the $(terraform output --raw function_name) part is to read function_name from the terraform state.

Explore terraform configuration

This guide is not intended to be a terraform manual. We will just explore terraform configuration to get a sense of this declarative approach to building infrastructure.

 1 terraform {
 2   backend "local" {
 3     path = "./.state/terraform.tfstate"
 4   }
 5 }
 6 
 7 variable "function_name" {
 8   type    = string
 9   default = "go-tf-handler-example"
10 }
11 
12 provider "aws" {}
13 
14 resource "aws_iam_role" "fn" {
15   name = "${var.function_name}-role"
16 
17   assume_role_policy = jsonencode({
18     Version = "2012-10-17"
19     Statement = [
20       {
21         Effect = "Allow"
22         Action = "sts:AssumeRole"
23         Principal = {
24           Service = "lambda.amazonaws.com"
25         }
26       }
27     ]
28   })
29 }
30 
31 resource "aws_lambda_function" "fn" {
32   role          = aws_iam_role.fn.arn
33   function_name = var.function_name
34   filename      = "../handler/function.zip"
35   runtime       = "provided.al2"
36   handler       = "bootstrap"
37   architectures = ["arm64"]
38 }
39 
40 output "function_name" {
41   value = var.function_name
42 }
43 
44 output "function_arn" {
45   value = aws_lambda_function.fn.arn
46 }

The first five lines define where terraform will save its state. The simplest method is to use a local filesystem. State will be saved into the .state folder into terraform folder (where main.tf is located).
In 7-10 we define function name as variable. You can change that in apply for example: terraform apply --var="function_name=my-function-name" --auto-approve.
Lines 14-29 will create IAM role for the Lambda function. It is referenced in line 32 when creating Lambda function. With this reference terraform knows that it first needs to create role resource because lambda function resource depends on the role.
Lines 31-38 are actual function creation. Again we provide the same information as in CLI; function_name, a zip file with Lambda deployment package (filename), runtime on which function is based (runtime) and architecture. Handler is fixed to the bootstrap for provided.al2 runtime.
Lines 40-46 define output variables. We can view them with terraform output command.

Cleanup

To remove all resources created in apply run:

terraform destroy --auto-approve

Conclusion

Congratulations! Now you know how to build the AWS Lambda function in Go, both via AWS CLI and Terraform. Thanks for following this tutorial. In the next part of this serverless series, I will break down how to set up an API Gateway and enable public access to created Lambda function.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL