export

FastAPI deployments with AWS Lambda and CDK

2024-07-14 | 8 min read
Armand Rego
For rapid development of APIs with an anticipated spiky usage pattern, almost nothing beats the combination of FastAPI and AWS Lambda for scalability and cost. In this post, we go over a simple FastAPI application, using DynamoDB as the persistence layer, and deploying serverlessly on AWS Lambda. All composed using Python CDK, of course!

What is FastAPI ?

FastAPI is a modern, fast, Python web framework for building APIs with first-class support for Pydantic data models and validation. It has quickly become one of the most popular frameworks in the web ecosystem and is in production use by many large organisations such as Netflix and Uber.

The rapid rise of FastAPI (as judged by Github stars at least...).

Additionally, FastAPI supports asynchronous programming with async/await syntax, making it easy to write efficient and scalable code. Other key features include built-in support for OpenAPI (formerly Swagger) documentation, WebSocket support, and integration with popular libraries like SQLAlchemy and Django ORM. Overall, FastAPI's unique combination of performance, ease of use, and strong typing makes it an attractive choice for building modern APIs in Python.

Building the application

The repository containing the code for this demo application can be found on Github, and is ready to clone and deploy with a simple cdk deploy command. Just remember that you will incur some (tiny) AWS charges by using the application!

Architecture

Being a small demo application, the system architecture is extremely simple and includes:

  • DynamoDB - the persistence layer of the application
  • Lambda - where the FastAPI application will actually run and handle incoming requests
  • API Gateway - provides the HTTP endpoint that can be reached via the internet
Architecture diagram for the demo application.

Infrastructure code

As the infrastructure required for the application is minimal, we define it in one CDK stack within api/infrastructure.py.

DynamoDB table

First, we define the DynamoDB table including the name and data type of the attributes used as partition and sort keys. We leave all other arguments such as billing_mode and encryption as default.

1
song_table = dynamodb.Table(
2
    self,
3
    "SongTable",
4
    partition_key=dynamodb.Attribute(
5
        name="Artist", type=dynamodb.AttributeType.STRING
6
    ),
7
    sort_key=dynamodb.Attribute(
8
        name="Title", type=dynamodb.AttributeType.STRING
9
    ),
10
)

Lambda function

To package and deploy the FastAPI application, we use the Function and Code constructs.

1
api_function = _lambda.Function(
2
    self,
3
    "ApiFunction",
4
    code=_lambda.Code.from_asset(
5
        "api/v1",
6
        # https://docs.aws.amazon.com/cdk/api/v1/docs/aws-lambda-readme.html#bundling-asset-code
7
        bundling=BundlingOptions(
8
            image=_lambda.Runtime.PYTHON_3_11.bundling_image,
9
            command=[
10
                "bash",
11
                "-c",
12
                (
13
                    "pip install --no-deps --platform manylinux2014_x86_64 -r requirements.txt -t"
14
                    "/asset-output && cp -au . /asset-output"
15
                ),
16
            ],
17
        ),
18
    ),
19
    handler="main.handler",
20
    runtime=_lambda.Runtime.PYTHON_3_11,
21
    environment={"DYNAMODB_SONG_TABLE_NAME": song_table.table_name},
22
)

The Code.from_asset() method uses an AWS provided Docker image and a magical cmd incantation to bundle the application code in the correct way to be deployed on Lambda; this tweet from Eric Johnson provides a more detailed explanation of what happens under the hood during the bundling process.

For simplicity, we pass in the DynamoDB table name to the Lambda function as an environment variable, however, in production usage and/or for more sensitive credentials, it would be more secure to use AWS Secrets Manager or Parameter Store.

FastAPI application

The core api/v1 directory contains the actual FastAPI application code with the following directory structure:

└── πŸ“v1
└── __init__.py
└── main.py
└── models.py
└── requirements.txt
└── πŸ“routers
└── __init__.py
└── songs.py

There is nothing special or complex about the API itself, users can interact with a Song model via three endpoints that are exposed:

  • Create
  • List
  • Delete (all)

main.py

This is where the FastAPI app is defined and the top-level routers and routes are configured.

1
from fastapi import FastAPI
2
from fastapi.routing import APIRouter
3
from mangum import Mangum
4
from routers import songs
5

6
app = FastAPI(title="FastAPI CDK Demo", summary="FastAPI CDK demo by Weird Sheep Labs")
7
router = APIRouter(prefix="/v1")
8
router.include_router(songs.router, prefix="/songs")
9
app.include_router(router)
10

11

12
@app.get("/")
13
async def root():
14
    return {"message": "Welcome to the FastAPI CDK demo by Weird Sheep Labs!"}
15

16

17
handler = Mangum(app)

We route the application under a top-level /v1 route which is a good practice to allow for API versioning, preventing the need to introduce breaking changes in the future. The songs router gets included under that, exposing a CRUD-like interface to interact with the data.

Importantly, we need to account for the fact that Lambda is invoked by event triggers but FastAPI expects standard HTTP requests; we use mangum to transform the Lambda event payloads into HTTP requests that can be routed and handled by FastAPI.

models.py

This is where we define our Song data model.

1
import os
2
from typing import Optional
3

4
from dyntastic import Dyntastic
5

6

7
class Song(Dyntastic):
8
    __table_name__ = os.environ["DYNAMODB_SONG_TABLE_NAME"]
9
    __table_host__ = os.getenv("DYNAMODB_HOST")
10
    __hash_key__ = "Artist"
11
    __range_key__ = "Title"
12

13
    Artist: str
14
    Title: str
15
    Album: str
16
    Key: Optional[str] = None
17
    Bpm: Optional[int] = None

Famously, the DynamoDB boto3 client is extremely verbose, so we use dyntastic, an ORM-like wrapper around the boto3 client with builtin Pydantic validation and type hinting.

We pull the table name and host from environment variables to make it easier to use in production as well as local development. Of note is the use of older DynamoDB terminology for the components of a compound primary key: partition/hash key and sort/range key. All the attributes are defined as class variables, with data types and default values included.

routers/songs.py

Finally, we define our endpoints to interact with the song data - for this demo we simply have a create, list and delete endpoint.

1
from fastapi.routing import APIRouter
2
from models import Song
3

4
router = APIRouter()
5

6

7
@router.get("/", tags=["songs"])
8
async def list_songs():
9
    return list(Song.scan())
10

11

12
@router.post("/", tags=["songs"])
13
async def create_song(song: Song):
14
    song.save()
15
    return song
16

17

18
@router.get("/delete", tags=["songs"])
19
async def delete_songs():
20
    for song in Song.scan():
21
        song.delete()

Conclusion

In this post, we've demonstrated how to take a simple FastAPI application, configure it to use DynamoDB for data persistence, and deploy it serverlessly to AWS Lambda using CDK.

In an upcoming post we'll be tackling the more complicated case of using a Postgres RDS instance as the database and handling migrations in a sensible, automated fashion - stay tuned!

Β© Weird Sheep Labs Ltd 2024
Weird Sheep Labs Ltd is a company registered in England & Wales (Company No. 15160367)
85 Great Portland St, London, W1W 7LT