chalice-http-toolkit
chalice-http-toolkit enables a Flask like website building experience using Chalice, AWS Lambda, API Gateway & CloudFront. It does this by bolting on Jinja2 templates, and CloudFront cache management layer.
Installation
Base installation via pip:
pip install chalice-http-toolkit
The above command only installs the base dependancies, there is also an additional extra_requires directive shown below that installs packages which would normally push your Lambda package over the 250MB limit.
For local testing the rest of the dependancies can be installed via:
pip install chalice-http-toolkit[layered]
For deployments to AWS Lambda the following layers should be used:
arn:aws:lambda:us-east-2:770693421928:layer:Klayers-python38-jinja2:2
arn:aws:lambda:us-east-2:770693421928:layer:Klayers-python38-Pillow:10
OR run
$ chalice-http-toolkit layers -r us-east-1 # Returns the latest compatible layer versions
arn:aws:lambda:us-east-1:770693421928:layer:Klayers-python38-jinja2:6
arn:aws:lambda:us-east-1:770693421928:layer:Klayers-python38-Pillow:15
Quick setup
challice-http-toolkit now comes with a CLI utility to quicky create a project, usage is as follows:
chalice-http-toolkit setup -n myapp -p . -e magic -r us-east-1
The above script does the following:
Creates your app.py, with handlers for index, static content, 404 and a event to keep the Lambda function warm.
Creates directory structure for templates, static content, chalice libraries.
Sets up Chalice layers (Pillow and Jinja2) which are required chalice-http-toolkit.
If
-e magic
is provided, then magic binaries are included inchalicelib/libs
directory. This is required because Lambda instances dont include these binaries so magic wont work without it.
The latest layers can be fetched using the following command:
chalice-http-toolkit layers -r us-east-1
Currently only Python 3.8 is supported for chalice-http-toolkit, this is because we would need to extract binaries for magic from different AWS Linux versions with varying Python versions and also build Pillow and Jinja2 layers for different Python versions.
Chalice config.json
A basic Chalice config.json
is defined below which has two stages, dev
is meant for local testing and prod
is the stage which gets deployed to AWS Lambda. The STAGE
environment variable must be defined for local testing to work around some differences between AWS API Gateway behaviour vs the way locally served API works.
{
"version": "2.0",
"app_name": "example",
"stages": {
"dev": {
"api_gateway_stage": "dev",
"lambda_timeout": 60,
"lambda_memory_size": 64,
"environment_variables": {
"STAGE": "dev"
},
},
"prod": {
"api_gateway_stage": "prod",
"layers": ["arn:aws:lambda:us-east-2:770693421928:layer:Klayers-python38-jinja2:2",
"arn:aws:lambda:us-east-2:770693421928:layer:Klayers-python38-Pillow:10"],
"lambda_timeout": 60,
"lambda_memory_size": 64,
"environment_variables": {
"STAGE": "prod"
}
}
}
}
Project Structure
Templates and static content needs to be placed in the chalicelib
directory because that is where additional content is packaged into the deployment by Chalice.
Architecture
Placing CloudFront in front of a chalice-http-toolkit website is not required, but if your website is popular it probably starts to make sense given that every invocation ends up costing more then it would for CloudFront to server the website.
ContentManager
This class is designed to support:
serving static content (such as Javascript/CSS). Although these might be better served by S3 depending on scale.
rendering Jinja2 templates
support generating 304 redirects
support returning JSON Responses
serving assets
converting images based on the accepts header in a request
cleaner handling of binary content
Every chalice-http-toolkit app requires a ContentManager be created, usually right after the Chalice app is created.
CloudFront
This class is designed to support:
caching static content by modifed dates
caching Jinja2 templates by hashing dependancies (ie arguments and child templates) without rendering.
caching assets
Using this module is optional, but it is best practice.
Considerations
There are a few differences between locally testing & a deployed instance of a Chalice website. They are detailed below.
Binary Content
Due to the way API Gateway handles binary content, the binary content is Base64 encoded in the absence of an Accepts header in the request. This restriction does not apply for local testing. In practice this means if you make a request to your Lambda function, API gateway will decode the Base64 data based on the Accepts header for us. Therefore if its not supplied, no decoding happens and a Base64 blob is returned.
Building Responsive Apps
Building a webapp with chalice-http-toolkit is a little bit different, the responsiveness of your application depends on having the shortess execution time possible Lambda functions. In practice if you start to make requests to other services like error/user tracking between returning data from your function, the users experience will get worse. A better approach is to put these requests onto a SQS queue and process them elsewhere given that SQS is usually pretty speedy.
To achieve this design, there is a function called TaskingControllerFactory
which enables asynchronous processing of messages through pushing to a FIFO SQS queue, and receiving in another Lambda function for processing. Below is an example of deferring requests to posthog.com.
from chalice import Chalice
from chalicelib.tracking import tasking
app = Chalice(app_name='testapp')
# ALL handler registration MUST happen in app.py as its the execution entrypoint when AWS
# executes our functions.
tasking.register_handler(app)
import traceback
import json
import logger
import os
from app import app
from chalice_http_toolkit.tasking import TaskingControllerFactory
tasking = TaskingControllerFactory(os.environ.get('TASKING_QUEUE_NAME'), "1")
def _on_new_message(record):
try:
message = json.loads(record.body)
except Exception:
traceback.print_exc()
t = message.get('type')
logger.debug(f'// [{t}] Processing Start')
if t == 'posthog_track':
user_id = message.get('user_id')
event_type = message.get('event_type')
event_attributes = message.get('event_attributes')
posthog.capture(user_id, event_type, properties=event_attributes)
posthog.flush()
elif t == 'posthog_register':
user_id = message.get('user_id')
event_attributes = message.get('event_attributes')
posthog.identify(user_id, properties=event_attributes)
posthog.flush()
elif t == 'posthog_link':
user_id = message.get('user_id')
distinct_id = message.get('distinct_id')
posthog.alias(user_id, distinct_id)
posthog.flush()
logger.debug(f'// [{t}] Processing End')
tasking.set_message_handler(_on_new_message)
def register(user_id, event_attributes=None):
if not posthog.disabled:
if event_attributes:
event_attributes = {}
s = json.dumps({'type': 'posthog_register',
'user_id': user_id,
'event_attributes': event_attributes})
tasking.post(s)
def track(user_id, event_type, event_attributes=None):
if not posthog.disabled:
if event_attributes:
event_attributes = {}
s = json.dumps({'type': 'posthog_track',
'user_id': user_id,
'event_type': event_type,
'event_attributes': event_attributes})
tasking.post(s)
def link(user_id, distinct_id):
if not posthog.disabled:
s = json.dumps({'type': 'posthog_link',
'user_id': user_id,
'distinct_id': distinct_id})
tasking.post(s)
Request & Reponse Limits
API Gateway has a request & response filesize limit of 10MB. This restriction does not apply for local testing.
Cold Starts
Chalice based websites are vulnerable to the Lambda Coldstart problem. A cold start happens when you execute an inactive function. The delay comes from AWS provisioning your selected runtime container and then running your function. Functions stay ‘warm’ for approximately 5 minutes, which means they respond to requests much quicker. After this period of time, the contianer is dropped by AWS and a cold start needs to happen.