Go on Lambda

Write a handler function, hand it to lambda.Start, and ship a bootstrap binary on the provided.al2023 runtime.

Syntaxlambda.Start(func(ctx context.Context, in In) (Out, error) { return Out{}, nil })

Lambda runs your code on machines you never see. You upload a program; AWS starts it when an event arrives, passes the event in as JSON, and takes the JSON you return. You pay for the milliseconds it ran and nothing while it is idle.

The handler

A handler is an ordinary Go function. lambda.Start, from github.com/aws/aws-lambda-go/lambda, takes it and runs the loop that fetches invocations. The usual form is func(ctx context.Context, in In) (Out, error), where In and Out are your own structs: the library decodes the event into In and encodes the returned Out back to JSON. A handler may take zero to two arguments, the first being a context.Context, and return zero to two values, the last being an error. A non-nil error marks the invocation failed. lambda.Start(h) is the no-options shorthand for lambda.StartWithOptions(h).

The provided runtime

There is no managed Go runtime. You use provided.al2023, which runs a file named bootstrap. So you cross-compile for Linux, name the output bootstrap, and zip it. The lambda.norpc build tag leaves out code that only the retired go1.x runtime needed, which makes the binary smaller.

Cold starts

The first invocation on a fresh instance must download and start your binary. That is a cold start, and a static Go binary does it in milliseconds, which is why Go suits Lambda. Build AWS clients once at package level, not inside the handler: that cost falls on the cold start and is then reused by every later invocation on the same instance. The context carries the invocation's deadline, so pass it to every call you make.

Example

Example · go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"strings"

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

// request is the shape of the event JSON: {"name":"ada"}.
type request struct {
	Name string `json:"name"`
}

// response is encoded back to JSON for the caller.
type response struct {
	Greeting string `json:"greeting"`
}

// handleRequest is the handler. Returning an error marks the invocation
// failed, and the error text is what appears in the logs.
func handleRequest(ctx context.Context, in request) (response, error) {
	if strings.TrimSpace(in.Name) == "" {
		return response{}, errors.New("name is required")
	}
	return response{Greeting: "hello " + in.Name}, nil
}

func main() {
	// Lambda sets AWS_LAMBDA_FUNCTION_NAME. Inside Lambda, start the loop
	// and let it call the handler for each event.
	if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" {
		lambda.Start(handleRequest)
		return
	}
	// On a laptop, call the handler directly. Tests do exactly this.
	out, err := handleRequest(context.Background(), request{Name: "ada"})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(out.Greeting)
}

// Output:
// hello ada

When to use it

  • Resize an image whenever one is uploaded to an S3 bucket, with no server to keep running.
  • Serve a small HTTP endpoint through a function URL or API Gateway instead of a cluster.
  • Run a nightly cleanup triggered on a schedule by EventBridge.
  • Drain an SQS queue whose traffic is bursty and idle most of the day.

More examples

Building and deploying the zip

This is the whole deployment. CGO_ENABLED=0 removes any dependency on the C library on the machine that runs it, so the binary cannot fail with a missing shared library. GOARCH must match the function's architecture setting: an arm64 binary on an x86_64 function fails at invocation with an exec format error. Build on your own machine or in CI, never on the Lambda host, since there is no Go toolchain there.

Example · bash
# A static Linux binary named bootstrap, built for arm64 (cheaper per ms).
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -tags lambda.norpc -o bootstrap .

zip function.zip bootstrap

aws lambda update-function-code \
  --function-name greet \
  --zip-file fileb://function.zip

# The function itself must have been created with a matching runtime and
# architecture: --runtime provided.al2023 --architectures arm64 --handler bootstrap

Clients built once, during the cold start

Do this with every client, database pool and parsed template. Lambda keeps an instance alive between invocations, so work done in init is paid for once per cold start and reused by thousands of later calls, while work done in the handler is paid for every time. Credential resolution is the expensive part, and it can involve a network call. The function's own IAM role supplies the credentials, so there is nothing to configure beyond the role's permissions.

Example · go
var (
	cfg   aws.Config
	store *s3.Client
)

// init runs once per instance, before the first invocation.
func init() {
	var err error
	cfg, err = config.LoadDefaultConfig(context.Background())
	if err != nil {
		log.Fatalf("load aws config: %v", err) // kill the instance, not one call
	}
	store = s3.NewFromConfig(cfg)
}

func handle(ctx context.Context, in request) (response, error) {
	out, err := store.GetObject(ctx, &s3.GetObjectInput{
		Bucket: aws.String("docs"),
		Key:    aws.String(in.Name),
	})
	if err != nil {
		return response{}, fmt.Errorf("get %s: %w", in.Name, err)
	}
	defer out.Body.Close()

	b, err := io.ReadAll(out.Body)
	if err != nil {
		return response{}, err
	}
	return response{Greeting: fmt.Sprintf("%d bytes", len(b))}, nil
}

The deadline and the request id

Useful whenever a handler does something slow enough to be cut off. Lambda stops the invocation at the configured timeout, so a call that would take longer than the remaining time is better refused than killed halfway through. Reading ctx.Deadline() lets you make that decision, and passing ctx onward means an AWS call is cancelled at the same moment rather than hanging. lambdacontext is github.com/aws/aws-lambda-go/lambdacontext, the same module as lambda.

Example · go
func handle(ctx context.Context, in request) (response, error) {
	// Lambda puts the function's remaining time on the context as a deadline.
	if deadline, ok := ctx.Deadline(); ok {
		left := time.Until(deadline)
		if left < 2*time.Second {
			return response{}, errors.New("not enough time left to finish")
		}
		log.Println("milliseconds left:", left.Milliseconds())
	}

	// The request id ties your log lines to this one invocation in CloudWatch.
	if lc, ok := lambdacontext.FromContext(ctx); ok {
		log.Println("request:", lc.AwsRequestID)
	}

	return response{Greeting: "hello " + in.Name}, nil
}

Discussion

  • Be the first to comment on this lesson.