Invalidating Your CloudFront Cache with a Lambda Written in Go
Posted July 19, 2021 by Trevor Roberts Jr ‐ 6 min read
I am putting the finishing touches on my blog's CI/CD pipeline, and the last item I need to address is how to refresh my CDN cache. Well, how about a Lambda?...written in Go!
For a quick recap of my blog architecture, I am generating static pages with Zola, storing them in S3, and using CloudFront as my CDN. I use the default CDN time to live (TTL) values to benefit from caching my content geographically closer to the site's readers.
When I publish new articles, I want the latest content to be available to readers instead of waiting for the cached content TTL to expire. The easiest way to do this is to use CloudFront's cache invalidation feature to purge the existing content from the CDN as soon as I upload a new article to the site's S3 bucket. Well, what options do I have for executing the cache invalidation?
Server vs Containers vs Serverless
I ultimately selected Lambda after considering all three AWS compute options to run my cache invalidation:
- EC2 (server)
- ECS\EKS (containers)
- Lambda (serverless)
My selection was influenced by the following factors:
Simplicity
CodePipeline can invoke a Lambda as part of the CI/CD process. If I were to use an EC2 instance or a container, I would need to integrate them to a Step Function workflow, or I would need to have them monitor EventBridge for CodePipeline job success events. The simpler option here is Lambda.
Cost
The Lambda free tier (1 million free requests per month and 400,000 GB-seconds of compute time per month) is cost-efficient for my use case. Let's say I somehow exhausted the free tier, my monthly costs would come out to ~$0.21 per month (i.e. an average of 2-3 invocations per month each lasting a maximum of 1106 ms). In contrast, if I use an EC2 instance or containers, I'd either run those resources 24/7 just waiting for my articles to upload or schedule weekly boots of those resources to check if any new content was uploaded in order to invalidate the cache.
My Go Lambda Function
I won't go through my source code in its entirety line-by-line. However, I'll step through the more important parts...
I used example Lambda functions from the documentation as a starting point (ex: here and here).
Then, I made sure the import section included the CloudFront and CodePipeline packages to invalidate the cache and to signal my pipeline execution that the Lambda succeeded, respectively.
package main
import (
"context"
"encoding/json"
"log"
"time"
"github.com/aws/aws-lambda-go/events"
runtime "github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudfront"
"github.com/aws/aws-sdk-go/service/codepipeline"
"github.com/aws/jsii-runtime-go"
)
In my handleRequest
function, I specified the CodePipeline event type since that service triggers my Lambda.
func handleRequest(ctx context.Context, event events.CodePipelineEvent) (string, error) {
I examined a sample CodePipeline event to see which fields I could use in my cache invalidation request. Now, I just needed a way to indicate my CloudFront Distribution ID.
I could have hardcoded my distribution ID in my Lambda, but then it would be difficult to repurpose the Lambda for other distributions. Instead, I configured my CodePipeline with a custom UserParameter field that contains my distribution ID. The Lambda function reads that custom field and includes it in the cache invalidation request.
result, erraws := cloudfrontClient.CreateInvalidation(&cloudfront.CreateInvalidationInput{
// CloudFront Distribution ID from the CodePipeline custom UserParameters
DistributionId: jsii.String(event.CodePipelineJob.Data.ActionConfiguration.Configuration.UserParameters),
InvalidationBatch: &cloudfront.InvalidationBatch{
CallerReference: jsii.String(time.Now().Format("20060102150405")),
Paths: &cloudfront.Paths{
Quantity: &quantity,
Items: items,
},
},
})
In the code above, the CallerReference
field is mandatory and must be unique. The simplest unique identifier I could think of was a timestamp.
The last code samples I'll share are my signals back to CodePipeline either that the cache invalidation request was a failure or was successful.
Failure if any errors encountered
_, errcode := codepipelineClient.PutJobFailureResult(&codepipelinPutJobFailureResultInput{
JobId: jsii.String(event.CodePipelineJob.ID),
FailureDetails: &codepipeline.FailureDetails{
Message: jsii.String(aerr.Error()),
Type: jsii.String("JobFailed"),
},
})
if errcode != nil {
log.Println(errcode)
}
Success if the invalidation succeeded
// insert codepipeline success here
_, errcode := codepipelineClient.PutJobSuccessResult(&codepipeline.PutJobSuccessResultInput{
JobId: jsii.String(event.CodePipelineJob.ID),
})
if errcode != nil {
log.Println(errcode)
}
The source code in its entirety can be found below and on GitHub
package main
import (
"context"
"encoding/json"
"log"
"time"
"github.com/aws/aws-lambda-go/events"
runtime "github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/cloudfront"
"github.com/aws/aws-sdk-go/service/codepipeline"
"github.com/aws/jsii-runtime-go"
)
func handleRequest(ctx context.Context, event events.CodePipelineEvent) (string, error) {
// event
eventJson, _ := json.MarshalIndent(event, "", " ")
sess, err := session.NewSession(&aws.Config{
Region: aws.String("us-east-1"),
})
if err != nil {
log.Println("Failed to create a session", err)
return "error", err
}
// Create a CloudFront client with additional configuration
cloudfrontClient := cloudfront.New(sess)
var quantity int64
var items []*string
paths := "/*"
items = append(items, &paths)
quantity = 1
// Create a CodePipeline client
codepipelineClient := codepipeline.New(sess)
result, erraws := cloudfrontClient.CreateInvalidation(&cloudfront.CreateInvalidationInput{
DistributionId: jsii.String(event.CodePipelineJob.Data.ActionConfiguration.Configuration.UserParameters),
InvalidationBatch: &cloudfront.InvalidationBatch{
CallerReference: jsii.String(time.Now().Format("20060102150405")),
Paths: &cloudfront.Paths{
Quantity: &quantity,
Items: items,
},
},
})
// Potential error conditions
if erraws != nil {
if aerr, ok := erraws.(awserr.Error); ok {
switch aerr.Code() {
case cloudfront.ErrCodeAccessDenied:
log.Println(cloudfront.ErrCodeAccessDenied, aerr.Error())
case cloudfront.ErrCodeMissingBody:
log.Println(cloudfront.ErrCodeMissingBody, aerr.Error())
case cloudfront.ErrCodeInvalidArgument:
log.Println(cloudfront.ErrCodeInvalidArgument, aerr.Error())
case cloudfront.ErrCodeNoSuchDistribution:
log.Println(cloudfront.ErrCodeNoSuchDistribution, aerr.Error())
case cloudfront.ErrCodeBatchTooLarge:
log.Println(cloudfront.ErrCodeBatchTooLarge, aerr.Error())
case cloudfront.ErrCodeTooManyInvalidationsInProgress:
log.Println(cloudfront.ErrCodeTooManyInvalidationsInProgress, aerr.Error())
case cloudfront.ErrCodeInconsistentQuantities:
log.Println(cloudfront.ErrCodeInconsistentQuantities, aerr.Error())
default:
log.Println(aerr.Error())
}
_, errcode := codepipelineClient.PutJobFailureResult(&codepipeline.PutJobFailureResultInput{
JobId: jsii.String(event.CodePipelineJob.ID),
FailureDetails: &codepipeline.FailureDetails{
Message: jsii.String(aerr.Error()),
Type: jsii.String("JobFailed"),
},
})
if errcode != nil {
log.Println(errcode)
}
}
return string("Fail"), nil
}
log.Println(result)
// insert codepipeline success here
_, errcode := codepipelineClient.PutJobSuccessResult(&codepipeline.PutJobSuccessResultInput{
JobId: jsii.String(event.CodePipelineJob.ID),
})
if errcode != nil {
log.Println(errcode)
}
return string(eventJson), nil
}
func main() {
runtime.Start(handleRequest)
}
References
- CodePipeline Event Structure
- Example CloudFront Cache Invalidation in Go
- Example CodePipeline Event for Lambda
If you found this article useful, let me know on Twitter!