Serverless Go web app on AWS with SWR

go
aws
cdk

I wanted to compile a binary which contains a whole Go web app and is deployed as a Lambda function to AWS. This function (or binary) should also have a cache from CloudFront in front of it. Using the newly introduced stale-while-revalidate support from CF, this app should be very fast without waiting for a cold start: Instead of waiting for a cold start, a cache entry is responded while a new response is retrieved from the origin in the background.

To make development easy, I also have a cli under cmd/rvweb which gives me a local binary to start a webserver with the handler from http.go. When deploying, this same handler is exposed as a Lambda handler and deployed.

|-- Makefile
|-- cdk
|   |-- README.md
|   |-- cdk.go
|   |-- cdk.json
|   |-- cdk.out
|   |-- cdk_test.go
|   |-- go.mod
|   `-- go.sum
|-- cmd
|   `-- rvweb
|-- go.mod
|-- go.sum
|-- http.go
`-- lambda
    `-- lambda.go

My example handler can be found in http.go which just returns a string and cache headers. With the values I used here (max-age=60, stale-while-revalidate=60) CloudFront will cache for a minute. And now, quoting from the AWS docs: “If a request is made after this period, CloudFront serves the stale content while concurrently sending a request to the new origin. The stale content is served for up to one minute while the content is being revalidated.”

package rvweb

import (
	"net/http"

	"github.com/labstack/echo/v4"
)

func HttpServer() *echo.Echo {
	e := echo.New()
	e.GET("/", func(c echo.Context) error {
		c.Response().Header().Set("Cache-Control", "max-age=60, stale-while-revalidate=60")
		return c.String(http.StatusOK, "Hello, World3")
	})

	return e
}

It returns *echo.Echo which embeds a http.Server. The cmd (or binary) package can now import this code and just start a web server for local development.

The Lambda handler is exposed via the lambda/lambda.go function:


package main

import (
	"github.com/akrylysov/algnhsa"
	"github.com/rverton/rvweb"
)

func main() {
	algnhsa.ListenAndServe(rvweb.HttpServer(), nil)
}

This gives me an easy way to expose the same http router/handler to a local dev server and to a Lambda function.

CDK (with Go) is then used to deploy the function with a Lambda function URL. I found a nice working stack here which I slightly adjusted to use caching.


// [...]

func NewCdkStack(scope constructs.Construct, id string, props *CdkStackProps) awscdk.Stack {
	var sprops awscdk.StackProps
	if props != nil {
		sprops = props.StackProps
	}
	stack := awscdk.NewStack(scope, &id, &sprops)

	bundlingOptions := &awslambdago.BundlingOptions{
		GoBuildFlags: &[]*string{jsii.String(`-ldflags "-s -w" -tags lambda.norpc`)},
	}
	f := awslambdago.NewGoFunction(stack, jsii.String("handler"), &awslambdago.GoFunctionProps{
		Runtime:      awslambda.Runtime_PROVIDED_AL2(),
		MemorySize:   jsii.Number(128),
		Architecture: awslambda.Architecture_ARM_64(),
		Entry:        jsii.String("../lambda"),
		Bundling:     bundlingOptions,
	})

	// Add a Function URL.
	lambdaURL := f.AddFunctionUrl(&awslambda.FunctionUrlOptions{
		AuthType: awslambda.FunctionUrlAuthType_NONE,
	})

	awscdk.NewCfnOutput(stack, jsii.String("lambdaFunctionUrl"), &awscdk.CfnOutputProps{
		ExportName: jsii.String("lambdaFunctionUrl"),
		Value:      lambdaURL.Url(),
	})

	lambdaURLDomain := awscdk.Fn_Select(jsii.Number(2), awscdk.Fn_Split(jsii.String("/"), lambdaURL.Url(), nil))
	lambdaOrigin := awscloudfrontorigins.NewHttpOrigin(lambdaURLDomain, &awscloudfrontorigins.HttpOriginProps{
		ProtocolPolicy: awscloudfront.OriginProtocolPolicy_HTTPS_ONLY,
	})

	cf := awscloudfront.NewDistribution(stack, jsii.String("customerFacing"), &awscloudfront.DistributionProps{
		DefaultBehavior: &awscloudfront.BehaviorOptions{
			AllowedMethods:       awscloudfront.AllowedMethods_ALLOW_ALL(),
			Origin:               lambdaOrigin,
			CachedMethods:        awscloudfront.CachedMethods_CACHE_GET_HEAD(),
			OriginRequestPolicy:  awscloudfront.OriginRequestPolicy_ALL_VIEWER_EXCEPT_HOST_HEADER(),
			CachePolicy:          awscloudfront.CachePolicy_CACHING_OPTIMIZED(),
			ViewerProtocolPolicy: awscloudfront.ViewerProtocolPolicy_REDIRECT_TO_HTTPS,
		},
		PriceClass: awscloudfront.PriceClass_PRICE_CLASS_100,
	})

	awscdk.NewCfnOutput(stack, jsii.String("cloudFrontDomain"), &awscdk.CfnOutputProps{
		ExportName: jsii.String("cloudfrontDomain"),
		Value:      cf.DomainName(),
	})

	return stack
}

// [...]

To make development and deployment easier, I’m using a Makefile which holds some nice tasks for testing, cleaning, building, running, watching and deploying. Most of the basic functionality can be found here.

See Also