AWS Architecture Blog
Serverless Dynamic Web Pages in AWS: Provisioned with CloudFormation
***This blog is authored by Mike Okner of Monsanto, an AWS customer. It originally appeared on the Monsanto company blog. Minor edits were made to the original post.***
Recently, I was looking to create a status page app to monitor a few important internal services. I wanted this app to be as lightweight, reliable, and hassle-free as possible, so using a “serverless” architecture that doesn’t require any patching or other maintenance was quite appealing.
I also don’t deploy anything in a production AWS environment outside of some sort of template (usually CloudFormation) as a rule. I don’t want to have to come back to something I created ad hoc in the console after 6 months and try to recall exactly how I architected all of the resources. I’ll inevitably forget something and create more problems before solving the original one. So building the status page in a template was a requirement.
The Design
I settled on a design using two Lambda functions, both written in Python 3.6.
The first Lambda function makes requests out to a list of important services and writes their current status to a DynamoDB table. This function is executed once per minute via CloudWatch Event Rule.
The second Lambda function reads each service’s status & uptime information from DynamoDB and renders a Jinja template. This function is behind an API Gateway that has been configured to return text/html instead of its default application/json Content-Type.
The CloudFormation Template
AWS provides a Serverless Application Model template transformer to streamline the templating of Lambda + API Gateway designs, but it assumes (like everything else about the API Gateway) that you’re actually serving an API that returns JSON content. So, unfortunately, it won’t work for this use-case because we want to return HTML content. Instead, we’ll have to enumerate every resource like usual.
The Skeleton
We’ll be using YAML for the template in this example. I find it easier to read than JSON, but you can easily convert between the two with a converter if you disagree.
---
AWSTemplateFormatVersion: '2010-09-09'
Description: Serverless status page app
Resources:
# [...Resources]
The Status-Checker Lambda Resource
This one is triggered on a schedule by CloudWatch, and looks like:
# Status Checker Lambda
CheckerLambda:
Type: AWS::Lambda::Function
Properties:
Code: ./lambda.zip
Environment:
Variables:
TABLE_NAME: !Ref DynamoTable
Handler: checker.handler
Role:
Fn::GetAtt:
- CheckerLambdaRole
- Arn
Runtime: python3.6
Timeout: 45
CheckerLambdaRole:
Type: AWS::IAM::Role
Properties:
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- sts:AssumeRole
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
CheckerLambdaTimer:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: rate(1 minute)
Targets:
- Id: CheckerLambdaTimerLambdaTarget
Arn:
Fn::GetAtt:
- CheckerLambda
- Arn
CheckerLambdaTimerPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:invokeFunction
FunctionName: !Ref CheckerLambda
SourceArn:
Fn::GetAtt:
- CheckerLambdaTimer
- Arn
Principal: events.amazonaws.com
Let’s break that down a bit.
The CheckerLambda is the actual Lambda function. The Code section is a local path to a ZIP file containing the code and its dependencies. I’m using CloudFormation’s packaging feature to automatically push the deployable to S3.
The CheckerLambdaRole is the IAM role the Lambda will assume which grants it access to DynamoDB in addition to the usual Lambda logging permissions.
The CheckerLambdaTimer is the CloudWatch Events Rule that triggers the checker to run once per minute.
The CheckerLambdaTimerPermission grants CloudWatch the ability to invoke the checker Lambda function on its interval.
The Web Page Gateway
The API Gateway handles incoming requests for the web page, invokes the Lambda, and then returns the Lambda’s results as HTML content. Its template looks like:
# API Gateway for Web Page Lambda
PageGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Name: Service Checker Gateway
PageResource:
Type: AWS::ApiGateway::Resource
Properties:
RestApiId: !Ref PageGateway
ParentId:
Fn::GetAtt:
- PageGateway
- RootResourceId
PathPart: page
PageGatewayMethod:
Type: AWS::ApiGateway::Method
Properties:
AuthorizationType: NONE
HttpMethod: GET
Integration:
Type: AWS
IntegrationHttpMethod: POST
Uri:
Fn::Sub: arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${WebRenderLambda.Arn}/invocations
RequestTemplates:
application/json: |
{
"method": "$context.httpMethod",
"body" : $input.json('$'),
"headers": {
#foreach($param in $input.params().header.keySet())
"$param": "$util.escapeJavaScript($input.params().header.get($param))"
#if($foreach.hasNext),#end
#end
}
}
IntegrationResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Content-Type: "'text/html'"
ResponseTemplates:
text/html: "$input.path('$')"
ResourceId: !Ref PageResource
RestApiId: !Ref PageGateway
MethodResponses:
- StatusCode: 200
ResponseParameters:
method.response.header.Content-Type: true
PageGatewayProdStage:
Type: AWS::ApiGateway::Stage
Properties:
DeploymentId: !Ref PageGatewayDeployment
RestApiId: !Ref PageGateway
StageName: Prod
PageGatewayDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn: PageGatewayMethod
Properties:
RestApiId: !Ref PageGateway
Description: PageGateway deployment
StageName: Stage
There’s a lot going on here, but the real meat is in the PageGatewayMethod section. There are a couple properties that deviate from the default which is why we couldn’t use the SAM transformer.
First, we’re passing request headers through to the Lambda in theRequestTemplates section. I’m doing this so I can validate incoming auth headers. The API Gateway can do some types of auth, but I found it easier to check auth myself in the Lambda function since the Gateway is designed to handle API calls and not browser requests.
Next, note that in the IntegrationResponses section we’re defining the Content-Type header to be ‘text/html’ (with single-quotes) and defining the ResponseTemplate to be $input.path(‘$’). This is what makes the request render as a HTML page in your browser instead of just raw text.
Due to the StageName and PathPart values in the other sections, your actual page will be accessible at https://someId.execute-api.region.amazonaws.com/Prod/page. I have the page behind an existing reverse-proxy and give it a saner URL for end-users. The reverse proxy also attaches the auth header I mentioned above. If that header isn’t present, the Lambda will render an error page instead so the proxy can’t be bypassed.
The Web Page Rendering Lambda
This Lambda is invoked by calls to the API Gateway and looks like:
# Web Page Lambda
WebRenderLambda:
Type: AWS::Lambda::Function
Properties:
Code: ./lambda.zip
Environment:
Variables:
TABLE_NAME: !Ref DynamoTable
Handler: web.handler
Role:
Fn::GetAtt:
- WebRenderLambdaRole
- Arn
Runtime: python3.6
Timeout: 30
WebRenderLambdaRole:
Type: AWS::IAM::Role
Properties:
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonDynamoDBReadOnlyAccess
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Action:
- sts:AssumeRole
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
WebRenderLambdaGatewayPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref WebRenderLambda
Action: lambda:invokeFunction
Principal: apigateway.amazonaws.com
SourceArn:
Fn::Sub:
- arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/*/*/*
- __ApiId__: !Ref PageGateway
The WebRenderLambda and WebRenderLambdaRole should look familiar.
The WebRenderLambdaGatewayPermission is similar to the Status Checker’s CloudWatch permission, only this time it allows the API Gateway to invoke this Lambda.
The DynamoDB Table
This one is straightforward.
# DynamoDB table
DynamoTable:
Type: AWS::DynamoDB::Table
Properties:
AttributeDefinitions:
- AttributeName: name
AttributeType: S
ProvisionedThroughput:
WriteCapacityUnits: 1
ReadCapacityUnits: 1
TableName: status-page-checker-results
KeySchema:
- KeyType: HASH
AttributeName: name
The Deployment
We’ve made it this far defining every resource in a template that we can check in to version control, so we might as well script the deployment as well rather than manually manage the CloudFormation Stack via the AWS web console.
Since I’m using the packaging feature, I first run:
$ aws cloudformation package \
--template-file template.yaml \
--s3-bucket <some-bucket-name> \
--output-template-file template-packaged.yaml
Uploading to 34cd6e82c5e8205f9b35e71afd9e1548 1922559 / 1922559.0 (100.00%) Successfully packaged artifacts and wrote output template to file template-packaged.yaml.
Then to deploy the template (whether new or modified), I run:
$ aws cloudformation deploy \
--region '<aws-region>' \
--template-file template-packaged.yaml \
--stack-name '<some-name>' \
--capabilities CAPABILITY_IAM
Waiting for changeset to be created.. Waiting for stack create/update to complete Successfully created/updated stack - <some-name>
And that’s it! You’ve just created a dynamic web page that will never require you to SSH anywhere, patch a server, recover from a disaster after Amazon terminates your unhealthy EC2, or any other number of pitfalls that are now the problem of some ops person at AWS. And you can reproduce deployments and make changes with confidence because everything is defined in the template and can be tracked in version control.