This is part two of the Serverless-AllTheThings three part blog series. You can review the code from this series on the Serverless-AllTheThings repo.
In part 1 of this blog series, I covered what serverless is, what it isn’t and some of my serverless lovers spats over the years. In part 2 I’m digging into an example serverless front end and providing you with all the code you need to spin up an environment of your own. In part 3 I’ll explore an example serverless back end.
KISS
The most rudimentary way to host a serverless website on AWS would be to put all of the static files in an S3 bucket and configure the bucket to serve the files as a website. It satisfies the requirements to be serverless (i.e. AWS manages the servers and those servers scale to handle the demand) and is extremely cost effective. For many use cases, I would advise stopping here to relish the simplicity. See below for an example AWS CloudFormation S3 bucket template.
S3BucketStatic: Properties: BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: "AES256" BucketName: "serverless-allthethings" Type: "AWS::S3::Bucket"
CloudFrontDistribution: Properties: DistributionConfig: Comment: !Ref "AWS::StackName" DefaultCacheBehavior: AllowedMethods: - "GET" - "HEAD" - "OPTIONS" CachedMethods: - "GET" - "HEAD" Compress: false DefaultTTL: 31536000 ForwardedValues: Headers: - "accept-encoding" - "x-uri" QueryString: false MaxTTL: 31536000 MinTTL: 0 SmoothStreaming: false TargetOriginId: !Join [ "", [ "s3:", !ImportValue "ServerlessAllTheThingsS3BucketStaticName", ], ] ViewerProtocolPolicy: "redirect-to-https" Enabled: true HttpVersion: "http2" IPV6Enabled: false Origins: - DomainName: !ImportValue "ServerlessAllTheThingsS3BucketStaticDomainName" Id: !Join [ "", [ "s3:", !ImportValue "ServerlessAllTheThingsS3BucketStaticName", ], ] OriginPath: !Sub "/${BranchSlug}" S3OriginConfig: OriginAccessIdentity: !Join [ "", [ "origin-access-identity/cloudfront/", !ImportValue "ServerlessAllTheThingsCloudFrontCloudFrontOriginAccessIdentityId", ], ] PriceClass: "PriceClass_All" Type: "AWS::CloudFront::Distribution"
- viewer request
- origin request
- origin response
- viewer response
This affords you with the opportunity to dynamically modify the request and/or response to your liking.
Livin’ Life On The Edge
To put things in CloudFront terminology, what we have so far is an S3 bucket origin server that contains all of our static files (js, css, images, etc.). What we want is to deliver a server-side rendered (SSR) single page app (SPA) via one of the Lambda@Edge viewer/origin request/response event types. All of the event types would work, but for a number of reasons (viewer request/response limits and sequential cold starts to name a couple) I have found it ideal to run SSR SPAs as an origin response. See below for an example AWS CloudFormation template with Lambda function and version resources and how they are associated with a CloudFront template.
LambdaFunctionApp: DependsOn: - "IamRoleLambdaApp" Properties: Code: S3Bucket: !ImportValue "ServerlessAllTheThingsS3BucketArtifactsName" S3Key: !Sub "${Commit}/app/lambda.zip" Description: !Sub "${AWS::StackName}-app" FunctionName: !Sub "${AWS::StackName}-app" Handler: "index.handler" MemorySize: 128 Role: !GetAtt "IamRoleLambdaApp.Arn" Runtime: "nodejs8.10" Timeout: 20 Type: "AWS::Lambda::Function" LambdaVersionApp: DependsOn: - "LambdaFunctionApp" Properties: FunctionName: !Ref "LambdaFunctionApp" Type: "AWS::Lambda::Version" CloudFrontDistribution: DependsOn: - "LambdaVersionApp" Properties: DefaultCacheBehavior: ... LambdaFunctionAssociations: - EventType: "origin-response" LambdaFunctionARN: !Ref "LambdaVersionApp" Type: "AWS::CloudFront::Distribution"
- Compression handling
- HTTP status code tweaking
- URL redirects and rewrites
Putting It All Together
Let’s take a look at a few requests to see how they’re handled via the origin request and origin response Lambda@Edge functions in the Serverless-AllTheThings GitHub repo.
Static Request – #1
First up is a simple static request for a file with the URI /favicon.ico.
- CloudFront checks to see if it is cached (let’s assume it isn’t)
- The origin request Lambda@Edge function determines the ideal compression algorithm for static assets (let’s assume it is brotli) and updates the S3 file path
- S3 contains favicon.ico.br and responds with its contents and HTTP status code 200
- The origin response Lambda@Edge function passes it through
- CloudFront caches the response based on the accept-encoding header
Static Request – #2
Now that favicon.ico.br is cached in CloudFront, let’s look at another request with the same URI and accept-encoding header.
- CloudFront checks for and returns the cached contents of favicon.ico.br
Static Request – #3
Suppose there is a new request where the accept-encoding header prioritizes gzip. In this case, even if favicon.ico.br is already cached, the first request would follow static flow #1 above and return the contents of favicon.ico.gzip. Subsequent requests with the same gzip prioritized accept-encoding header would then follow static flow #2 above (where CloudFront would return the contents of favicon.ico.gzip).
Dynamic Request – #1
Now let’s take a look at a dynamic request for the URI / (i.e. the Home view).
- CloudFront checks to see if it is cached (let’s assume it isn’t)
- The origin request Lambda@Edge function determines the ideal compression algorithm for static assets and updates the S3 file path (let’s assume it is brotli)
- S3 does not contain a file and responds with HTTP status code 403
- The origin response Lambda@Edge function initiates the SSR process, renders the html for the Home view, determines the ideal compression algorithm for dynamic assets (let’s assume it is gzip), compresses the html, sets a short-lived cache-control timeout, and converts the HTTP status code to 200
- CloudFront caches the response based on the accept-encoding header
Dynamic Request – #2
Now that the Home view is cached in CloudFront, let’s look at another request with the same URI and accept-encoding header within the cache-control timeout.
- CloudFront checks for and returns the cached, rendered page
Dynamic Request – #3
Once the cache-control timeout has expired, the next request will follow dynamic request flow #1 above and then subsequent requests within the new cache-control timeout will follow dynamic request flow #2 above.
Dynamic Request – #4
Suppose there is a new request where the accept-encoding header prioritizes identity (i.e. no encoding). In this case, even if the Home view is already cached, the first request would follow dynamic request flow #1 above and return the rendered view without compression. Subsequent requests with the same identity-prioritized accept-encoding header would then follow dynamic request flow #2 above.
Instantaneous Scaling
In the above section I covered the basic requests under a minimum volume of users, but it doesn’t capture the true value that serverless brings to the table. Let’s suppose your website is featured in a prime position on Hackernews and a tsunami of users heads your way. How do you think the website will handle the flood of traffic?
Flood Scenario
Seconds after your website goes viral, let’s assume 1,000,000 new users simultaneously request your website home page (/) every minute (i.e. all one million requests happen on the first second each minute).
Minute 1
- One of the requests will follow the dynamic request flow #2 above. The other 999,999 requests will pause until the first request completes and then immediately respond with the cached content
- This assumes all of the accept-encoding headers are the same. If not, there will be one request per unique accept-encoding header
Minute 2+
- All requests will receive the cached response until the cache-control timeout expires. At this point it will then follow the minute one flow above.
No requests are dropped and the handling of traffic instantly scales from zero to one million requests per minute. In non-Lambda environments, variations in traffic are traditionally handled by scaling a cluster of servers up and down, but it is far from instantaneous (i.e. during unexpected scale up events users will experience slower response times and/or requests will be dropped). Furthermore, while those servers are up, you are paying for them the whole time regardless of whether or not they are handling requests. Now what about the costs? Won’t one million serverless requests per minute be really, really expensive? Let’s do the math.
Cost
Lambda@Edge functions cost $0.60 per one million requests and $0.00000625125 for every 128MB-second used (metered at 50ms increments). In this scenario, let’s assume:
- Origin request functions complete in under 50ms
- Origin response functions complete in under 150ms
- This flood of traffic occurs for 8 hours a day and there are zero requests in the other 16 hours each day
- There are 200 unique requests every five minutes (due to accept-encoding headers, expired cache control and unique URIs)
- Average transfer is 30KB
8 hours x 60 minutes x 1 million requests = 480 million requests
8 hours x 60 minutes / 5 minutes x 200 requests = 19,200 unique requests
480 million x (30/1024/1024)GB = 13.4 TB transferred
Lambda price = 0.0192 x $0.6 + 19,200 x (0.05 + 0.15)ms x $0.00000625125 = $0.01 + $0.02 = $0.03
In other words, the dynamic server cost (i.e. Lambda) for one flood day is about three cents. The supporting infrastructure (CloudFront and Route 53) is likely used in both serverless and non-serverless environments and is where the bulk of the costs will reside, but for completeness the math is below. In a worst case scenario where nothing is cached by users’ browsers:
CloudFront transfer price = $0.085/GB x 10TB + $0.08/GB x 3.4TB = $1,150
CloudFront request price = $0.1 x (480 million / 10,000) = $4,800
CloudFront total price = $5,950
Route 53 price = $0.4 * 480 = $192
Note: These are back-of-envelope calculations, actual costs will vary.
Performance
Serverless is faster than fast. Quicker than quick. It is Lightning! (Ka-Chow!)
For requests where no Lambda functions are warm nor has anything been cached by CloudFront (i.e. the worst case scenario), it takes about 1.61 seconds for the request to complete and the DOM content to be loaded.
In Summary
Serverless is awesome and is the perfect choice for both static and dynamic front ends. Stay tuned for part 3 of this serverless blog series where we’ll explore an example serverless back end and provide you with everything you need to spin up an environment of your own.
Lucas Still
Related Posts
-
Serverless - AllTheThings (Part 1 of 3)
Serverless software architecture reduces maintenence and operation costs of computing, provides scalable, on demand data…
-
Promoting New Blog Entries with AWS Lambda and Slack
Here at Modus Create, we're avid users of Slack as a team collaboration tool and…