Serverless Go web app on AWS with SWR

go

aws

cdk

lambda

2023-07-7

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.