Illustration of the phrase 'Go Lambda'

Getting Started with Go AWS Lambda

Go


Serverless functions, or Functions as a Service (FaaS), are functions deployed individually and executed on a server managed by a cloud provider. They’re part of a broader model called serverless architecture or serverless computing and can offer significant benefits in certain scenarios.

I recently used a serverless function to process a lead form submission for a marketing site. For this common task, I would usually create a VPS (virtual private server) to host the marketing site and handle the form submission in the same VPS on the backend. But spooling up a dedicated VPS for a simple marketing site, which is mostly static, is wasteful. It’s the type of site that could easily be deployed statically, using something like a CDN. It’s an ideal scenario for serverless functions.

There are several benefits to the serverless model applicable in this scenario. One of the biggest advantages—and something that makes this a compelling sale to stakeholders—is a significant cost reduction. The price model for AWS’s VPS, EC2, is complex and multidimensional. Cost is determined by several parameters, including resource size, run time, CPU utilization, etc. At a simple level, it boils down to a VPC instance running 24/7. You incur charges the entire time, even if the server is idle.

Serverless functions have a simpler cost structure where the price is determined by the number of invocations, run time, and memory use. When a serverless function is invoked, you incur a single charge. It’s a per-request model versus a per hour model, like a ‘pay-as-you-go’ model. Idle time is basically free.

There are more benefits (and drawbacks) to the serverless model that I won’t cover here. For more information, I recommend Mike Roberts’ comprehensive article on serverless architectures.

In this guide, I cover the basics of getting started with serverless functions using Go and AWS.

Go AWS Lambda Workflow

Several providers support creating serverless functions using Go, including Google, IBM, and AWS. I use AWS Lambda (their brand name for FaaS) in this example because we have a lot of existing infrastructure through AWS.

Apart from Go itself, there are several components in the serverless workflow:

  • aws-sdk-go - Official AWS SDK for Go.
  • AWS CLI - Used to manage AWS from the command line.
  • AWS Lambda - Service that executes serverless functions.
  • API Gateway - Used to map HTTP REST endpoints to lambda functions.
  • CloudWatch Logs - Used to log information from lambda functions.

Creating an AWS Lambda Function in Go

Writing Lambda functions is simple. At a basic level, each Lambda function consists of an entrypoint and a handler function. Creating a lambda entry point involves calling the Start function from the lambda package in the AWS SDK for Go, which takes a handler function as its single argument.

The basic structure for a lambda function:

// main.go
package main

import "github.com/aws/aws-lambda-go/lambda"

func handler() {}

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

There are several valid function signatures for the handler function. With a context argument, the handler function has access to the invocation context, which includes information on the environment, client, etc. With an events argument, the handler has access to information specific to the request, or event, that triggered the function.

// these are all valid handler function signatures
func handler() {}
func handler(ctx context.Context) {}
func handler(req events.APIGatewayProxyResponse) {}
func handler(ctx context.Context, req events.APIGatewayProxyRequest) {}

An Example Lambda Function

In the following example, we process a contact, or lead form submission from an HTTP request. Our request body has a lead as a JSON encoded string. Our response will return the lead and a timestamp as a JSON encoded string.

The handler function, handleLead, accepts an API Gateway request event as its only argument and returns an API Gateway response and error. The API Gateway request has typical HTTP request data, such as headers, query params, a body, etc. The API Gateway response can accept typical HTTP response data, such as a status code, headers, a body, etc. This function is going to be invoked via the API Gateway. If we were expecting a different service to invoke the function, such as a database, we might use the DynamoDBEvent, also from the events package.

Within the handler function, we’ll log the request body. The lambda function uses CloudWatch Logs as its standard logger by default.

If the lead request can be unmarshaled and we encounter no errors, we return a response with the lead data, timestamp, and a successful status code. Otherwise, we log an error and respond with a server error status code.

// main.go
package main

import (
  "encoding/json"
  "github.com/aws/aws-lambda-go/events"
  "github.com/aws/aws-lambda-go/lambda"
  "log"
  "net/http"
  "time"
)

type lead struct {
  Name  string `json:"name"`
  Email string `json:"email"`
}

type response struct {
  Lead      lead      `json:"lead"`
  Timestamp time.Time `json:"timestamp"`
}

func handleLead(request events.APIGatewayProxyRequest) (
  res events.APIGatewayProxyResponse, err error) {
  log.Printf("INFO: body: %s", request.Body)
  var l lead
  // unmarshal the request body into a lead struct and
  // log and return an error code if it fails
  if err := json.Unmarshal([]byte(request.Body), &l); err != nil {
    log.Printf("ERROR: unable to unmarshal request: %s", err.Error())
    res.StatusCode = http.StatusInternalServerError
    return res, err
  }

  // form a response using the lead request data and the current time
  resp := &response{
    Lead:      l,
    Timestamp: time.Now().UTC(),
  }
  body, err := json.Marshal(resp)
  if err != nil {
    log.Printf("ERROR: unable to marshal request: %s", err.Error())
    res.StatusCode = http.StatusInternalServerError
    return res, err
  }

  // finally return the response with a 200 status code
  res.Body = string(body)
  res.StatusCode = http.StatusOK
  return res, nil
}

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

And that’s a super basic lambda function. To invoke it, we need to build and deploy it.

Deploying Lambda Functions

IAM and Policies

Before running, Lambda functions need to have permissions set. Permissions in AWS are defined by IAM roles and policies. A function assumes a role, which has policies attached. For example, if we wanted to send an email from our lambda function via AWS’ Simple Email Service (SES), we’d need to give our Lambda access to the SES service.

We’re going to create a simple role for executing lambda functions and attach policies to the role allowing it to access resources.

The following example passes a trust policy via a local file, but it can also be inlined with the --cli-input-json flag.

Note: there are several places where {iam} is used as a placeholder for the IAM user ID. Use the appropriate IAM user ID instead.

$ aws iam create-role --role-name lambda-simple \
--assume-role-policy-document file://trust-policy.json
{
    "Role": {
        "Path": "/",
        "RoleName": "lambda-simple",
        "RoleId": "AROA3FQMJ0I5QSHLOZXN5",
        "Arn": "arn:aws:iam::{iam}:role/lambda-simple",
        "CreateDate": "2020-06-25T02:00:07Z",
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {
                    "Effect": "Allow",
                    "Principal": {
                        "Service": "lambda.amazonaws.com"
                    },
                    "Action": "sts:AssumeRole"
                }
            ]
        }
    }
}

🎗 Keep a note of the Arn value returned. It’s going to be used when deploying the Lambda.

A simple trust policy for AWS Lambda:

// trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

After the role has been created, we can attach any number of policies to it. We’ll attach policies allowing the function to execute and access CloudWatch Logs.

Note: these commands don’t return output if the operation was successful.

Attach a policy allowing the function to execute:

aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AWSLambdaExecute \
--role-name lambda-simple

Attach a policy allowing the function to access CloudWatch Logs:

aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/CloudWatchLogsFullAccess \
--role-name lambda-simple

With these policies in place, we’ll next build and upload the function.

Build Lambda and Upload

Go Lambda functions are run from a binary compiled for Linux. The binary must be included in a zip file along with any dependent assets. In this example, there’s a single main binary. If we needed to include other files such as templates, we’d add them to the zip archive as well.

Build On Linux

Building on Linux is straightforward: we build a binary from our main.go file and zip it.

go build main main.go
zip main.zip main

Build On Windows

Building on Windows is more involved because of the way Windows handles file permissions. The Lambda function is executed on Linux and needs open execution permissions.

To get around this, use the zip tool included in the aws-lambda-go package. The zip tool adds the appropriate permissions to the binary before zipping.

# get the zip tool
go get -u github.com/aws/aws-lambda-go/cmd/build-lambda-zip
# build for linux
set GOOS=linux
go build -o main main.go
# create a zip file with the zip tool
%userprofile%/Go/bin/build-lambda-zip.exe -o main.zip main

With the zip containing the built application, we can create and upload the AWS Lambda with the create-function subcommand. The value of the --role flag is the Arn of the role we previously created.

$ aws lambda create-function --function-name lambda-lead \
--runtime go1.x \
--zip-file fileb://main.zip \
--handler main \
--role arn:aws:iam::{iam}:role/lambda-simple
{
    "FunctionName": "lambda-lead",
    "FunctionArn": "arn:aws:lambda:us-west-2:{iam}:function:lambda-lead",
    "Runtime": "go1.x",
    "Role": "arn:aws:iam::{iam}:role/lambda-simple",
    "Handler": "main",
    "CodeSize": 5158043,
    "Description": "",
    "Timeout": 3,
    "MemorySize": 128,
    "LastModified": "2020-06-23T02:11:54.120+0000",
    "CodeSha256": "B6tVYAT+GFuA1QhvGB7k+FHQqZzSzkFSd1p2yypvM5U=",
    "Version": "$LATEST",
    "TracingConfig": {
        "Mode": "PassThrough"
    },
    "RevisionId": "9f3af20e-f1g2-4271-a4f1-3gh850bfa7ac",
    "State": "Active",
    "LastUpdateStatus": "Successful"
}

🎗 Keep a note of the FunctionArn value returned. It’s going to be used when creating the API Gateway.

Update Lamba

To update an already deployed Lambda function, build and zip the function as in the previous steps. Then use the update-function-code subcommand, which requires the lambda function name and a path to the zip file.

aws lambda update-function-code --function-name lambda-lead --zip-file fileb://main.zip

Invoking Lambda with HTTP

The Lambda is going to be accessed via HTTP. We’re going to use API Gateway to map a dedicated HTTP endpoint to our lambda function using the create-api subcommand.

The --target is the ARN of the function created in the previous section (the value for FunctionArn).

$ aws apigatewayv2 create-api --name lambda-lead \
--protocol-type HTTP \
--target arn:aws:lambda:us-west-2:{iam}:function:lambda-lead
{
    "ApiEndpoint": "https://k4sdfh89l.execute-api.us-west-2.amazonaws.com",
    "ApiId": "k4sdfh89l",
    "ApiKeySelectionExpression": "$request.header.x-api-key",
    "CreatedDate": "2020-06-25T02:14:41Z",
    "Name": "lambda-lead",s
    "ProtocolType": "HTTP",
    "RouteSelectionExpression": "$request.method $request.path"
}

🎗 Keep a note of the ApiEndpoint and ApiId values returned. They identify the API and endpoints for requests.

Finally, the API Gateway needs permission to invoke its associated lambda function. We give it permission by updating the lambda’s resource policy with the newly created API Gateway ARN:

aws lambda add-permission --statement-id lamba-lead-api-gateway-permission \
--action lambda:InvokeFunction \
--function-name arn:aws:lambda:us-west-2:{iam}:function:lambda-lead \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:us-west-2:{iam}:k4sdfh89l/*/$default"

Test from End to End

Now that the lambda function is fully deployed, we can test accessing it via HTTP. We’ll make a curl request to the API endpoint with test lead data.

Our endpoint returns the lead along with a timestamp. Success!

$ curl -H "Content-Type: application/json" \
-d "{\"name\": \"Testy\", \"email\": \"test@test.com\"}" \
https://k4sdfh89l.execute-api.us-west-2.amazonaws.com
{"lead":{"name":"Testy","email":"test@test.com"},"timestamp":"2020-06-19T03:40:29.451817088Z"}

You can verify that your lambda invocation was logged to CloudWatch by listing the log streams for the log group, named for the lambda function:

aws logs describe-log-streams --log-group-name "/aws/lambda/lambda-lead"

You can see the request body that we submitted by inspecting a particular log stream:

aws logs get-log-events --log-group-name "/aws/lambda/lambda-lead" \
--log-stream-name 2020/06/25/[$LATEST]3c93779ddb224i6982fg4e3cb928c2f5

Next Steps

In this guide, we created a simple serverless function using Go. We deployed our function and set up an API endpoint to access it via HTTP. This covers a lot of ground, yet it’s only a start. There’s plenty of room for improvement.

Here’s a short list of possible next steps:

  • Validate the request as part of the request processing.
  • Configure a CORS policy and API throttling in the API Gateway.
  • Send emails using AWS SES as part of the request processing.