Serverless Go web app on AWS with SWR

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

Title Tags Link
Terraform bootstrap with statefile on S3 Read →
tevents: event logger and job monitor for tailnets Read →
Queueing with PostgreSQL and Go Read →
hntr, shareable workspaces for target data Read →
Webanalyze, utility to uncover technologies used on websites. Read →