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:

  1. Creates your app.py, with handlers for index, static content, 404 and a event to keep the Lambda function warm.

  2. Creates directory structure for templates, static content, chalice libraries.

  3. Sets up Chalice layers (Pillow and Jinja2) which are required chalice-http-toolkit.

  4. If -e magic is provided, then magic binaries are included in chalicelib/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

class chalice_http_toolkit.content.ContentManager[source]

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

class chalice_http_toolkit.cloudfront.CloudFront[source]

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.

TaskingController

class chalice_http_toolkit.tasking.TaskingController

This class is designed to support:

  • processing of asynchronous tasking via a FIFO SQS Queue.

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.