How to Deploy an AI FastAPI Application to App Runner with Pulumi

Iyanuoluwa AjaoIyanuoluwa Ajao
7 min read

Introduction

In the previous article, "Building a Story Generation App with FastAPI, AWS Bedrock, and Langchain," you created a web application that generates creative stories based on user input. You used FastAPI for the backend, Langchain for interacting with large language models, and AWS Bedrock as your LLM provider. Now, it's time to deploy your application to the cloud so that users can access it.

In this article, you'll use AWS App Runner, a fully managed service that makes it easy to build, deploy, and scale containerized applications quickly. You'll also leverage Pulumi, an Infrastructure as Code (IaC) tool, to define and manage our AWS infrastructure in a programmatic and repeatable way.

Prerequisites

Before you begin, make sure you have the following:

  • An AWS account with appropriate permissions to create resources (IAM roles, ECR repositories, App Runner services).

  • A Pulumi account and the Pulumi CLI installed and configured.

  • Docker installed and configured on your local machine.

  • The AWS CLI installed and configured.

  • Familiarity with basic Docker and AWS concepts.

  • Completed the steps outlined in the "Building a Story Generation App with FastAPI, AWS Bedrock, and Langchain" article.

  • A repository containing the code for the Story Generation App.

  • The requirements.txt file.

Building and Containerizing the Application

Your Story Generation App uses FastAPI as its backend framework and leverages Langchain to interact with AWS Bedrock for story generation. To deploy this application, you first need to package it into a Docker container.

Dockerfile Explanation:

The Dockerfile defines how your application's container image will be built. Let's break down each instruction:

Dockerfile

FROM python:3.12-slim 

LABEL version="1.0.2" 

WORKDIR /code 

COPY requirements.txt /code/requirements.txt 
RUN python3 -m venv venv && \ 
     . venv/bin/activate && \ 
     pip install --no-cache-dir -r /code/requirements.txt 

COPY . /code/app 

ENV PYTHONDONTWRITEBYTECODE=1 \ 
    PYTHONUNBUFFERED=1 \ 

    AWS_ACCESS_KEY_ID=your-aws-access-key-id \
    AWS_SECRET_ACCESS_KEY=your-secret-access-key \
    AWS_REGION_NAME=your-aws-region-name \


CMD ["venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • FROM python:3.12-slim: You start with a slim Python 3.12 base image to keep the image size small.

  • LABEL version="1.0.2": This adds metadata to our image, specifying the version.

  • WORKDIR /code: You set the working directory inside the container to /code.

  • COPY requirements.txt /code/requirements.txt: You copy the requirements.txt file into the container.

  • RUN python3 -m venv venv ...: This creates a virtual environment, activates it, and installs the dependencies listed in requirements.txt.

  • COPY . /code/app: You copy the entire application code into the app folder.

  • ENV PYTHONDONTWRITEBYTECODE=1 ...: These environment variables are set for optimization. Important: The AWS_ACCESS_KEY_ID, AWS_SECRET_KEY, and AWS_REGION_NAME should be replaced with appropriate AWS credentials or environment variables when deploying to production.

  • CMD ["venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]: This specifies the command to run when the container starts, launching the FastAPI application using Uvicorn on port 8000.

Building the Docker Image:

To build the Docker image, navigate to the directory containing your Dockerfile in your terminal and run the following command:

Bash


docker build -t story-generator .

This command builds the image and tags it as story-generator.

Pushing the Docker Image to Amazon ECR

Amazon Elastic Container Registry (ECR) is a fully managed Docker container registry that makes it easy to store, manage, and deploy Docker container images. You'll use ECR to store your Story Generation App's image.

Creating an ECR Repository (if not already done in Pulumi):

While your Pulumi code will handle creating the ECR repository, you can also do this manually through the AWS console or CLI. If you prefer to use the console, navigate to the ECR service, click "Create repository," and follow the instructions.

Authenticating Docker to ECR:

To push images to ECR, you need to authenticate Docker. Use the following AWS CLI command to get the login password and pipe it to docker login:

Bash


aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin <account-id>.dkr.ecr.<region>.amazonaws.com

Replace <account-id> with your AWS account ID and <region> with your desired AWS region (e.g., us-east-1).

Tagging the Image:

Before pushing, you need to tag the image with the ECR repository URI:

Bash


docker tag story-generator:latest <account-id>.dkr.ecr.us-east-1.amazonaws.com/web-projects:latest

Pushing the Image:

Finally, push the image to ECR:

Bash


docker push <account-id>.dkr.ecr.us-east-1.amazonaws.com/web-projects:latest

Defining Infrastructure as Code with Pulumi

Pulumi allows you to define our infrastructure using familiar programming languages. In this case, you are using Python. Your Pulumi project consists of several files:

  • __main__.py: The main entry point for our Pulumi program.

  • apprunner.py: Defines resources and logic related to AWS App Runner.

  • ecr_instance.py: Defines resources and logic related to Amazon ECR.

  • Pulumi.dev.yaml: Configuration file for the development stack.

  • Pulumi.yaml: Project metadata file.

Let's break down the key parts of the code:

apprunner.py Deep Dive:

Python

import functools
from enum import Enum
from typing import Mapping

import pulumi
from pulumi_aws.apprunner import Service, ServiceSourceConfigurationArgs,ServiceInstanceConfigurationArgs, 
from pulumi_aws.apprunner.outputs import ServiceSourceConfigurationCodeRepository


config = pulumi.Config()

def manage_instance_configuration(instance_configuration: ServiceInstanceConfigurationArgs):
    def decorated(func):
        @functools.wraps(func)
        def wrapped(*args, **kwargs):
            kwargs.update(instance_configuration=instance_configuration)
            result = func(*args, **kwargs)
            return result

        return wrapped

    return decorated

def set_service_source_config(access_role_arn: str, auto_deployments_enabled: bool,
                                image_identifier: str, image_repository_type: str,
                                start_command: str = None, port: str = None,
                                runtime_environment_variables: Mapping[str, str] = None,
                                connection_arn: str = None):
    # ... (function body) ...

source_config = set_service_source_config(access_role_arn=ACCESS_ROLE_ARN,
                                        auto_deployments_enabled=True,
                                        image_identifier=IMAGE_IDENTIFIER,
                                        image_repository_type=str(ImageRepositoryType.ECR.value),
                                        port=PORT,
                                        runtime_environment_variables=RUNTIME_ENVIRONMENT)

# ... (other functions and classes) ...

@manage_instance_configuration(instance_configuration=service_instance_configuration)
def create_service(resource_name_: str, service_name_: str, source_configuration_: ServiceSourceConfigurationArgs,
                    **kwargs):
    # ... (function body) ...
  • set_service_source_config: This function creates the ServiceSourceConfigurationArgs object, which defines the source of our application code (in this case, an image in ECR). It takes various parameters like access_role_arn, auto_deployments_enabled, image_identifier, image_repository_type, port, and runtime_environment_variables.

  • source_config: This variable holds the source configuration created using set_service_source_config, using values like ACCESS_ROLE_ARN, IMAGE_IDENTIFIER, PORT, and RUNTIME_ENVIRONMENT defined earlier in the file. It tells App Runner where to find our application code, how to build it, and how to run it.

    • Inside this function the role of ServiceSourceConfigurationImageRepositoryArgs and ServiceSourceConfigurationImageRepositoryImageConfigurationArgs are to configure the image repository and image configuration settings, respectively, for an AWS App Runner service's source configuration. They specify the location of the container image, how App Runner should access it, and any runtime configurations for the image.
  • service_instance_configuration: This variable defines the instance configuration for our App Runner service, including CPU and memory.

  • manage_instance_configuration: This is a decorator that adds instance configuration settings to the create_service function. The decorator manage_observability_configuration add observability configuration to the create_service function.

  • create_service: This function creates the AWS App Runner service using pulumi_aws.apprunner.Service.

ecr_instance.py Deep Dive:

Python

import functools
from enum import Enum
from typing import Sequence, Mapping

from pulumi_aws.ecr import Repository


def create_repository(resource_name_: str, name_: str, tags: Mapping[str, str] = None, force_delete: bool = False,
                        **kwargs):
    repository = Repository(resource_name=resource_name_, name=name_, force_delete=force_delete, tags=tags, **kwargs)
    return repository
  • create_repository: This function creates an ECR repository. The decorators are used to provide default values. If these values are not provided during the function call, the decorators will add them in.

__main__.py Deep Dive:

Python

import pulumi
import pulumi_aws


from .ecr_instance import create_repository
from .app_runner_instance import create_service, source_config


config = pulumi.Config()

story_generator_project_availability_zone = pulumi_aws.config.region



ecr_result = create_repository(resource_name_='web-projects', name_='web-projects')

app_runner_result = create_service(resource_name_='story-generator', service_name_='story-generatory-dev',
                                    source_configuration_=source_config)


pulumi.export('ecr', ecr_result.repository_url)
pulumi.export('ecr_registry', ecr_result.registry_id)
  • create_repository(...): Calls the create_repository function to create our ECR repository.

  • create_service(...): Calls the create_service function to create our App Runner service, passing in the source_config.

  • pulumi.export(...): These lines export important values from our stack, such as the RDS instance address and the ECR repository URL.

  • config.require_secret: These lines are used to fetch secret values from the Pulumi configuration. Secrets like database passwords should be stored securely and not hardcoded.

Deploying to AWS App Runner

Now that you have your Pulumi code defined, let's deploy your application to AWS App Runner:

  1. Initializing the Pulumi Stack (if new):

    If you haven't already initialized a Pulumi stack, run:

    Bash

    
     pulumi stack init dev
    
  2. Setting AWS Region:

    Set the AWS region where you want to deploy your resources:

    Bash

    
     pulumi config set aws:region us-east-1
    
  3. Setting Configuration Values:

    Set the required configuration secrets using pulumi config set --secret:

    Bash

    
     pulumi config set --secret CONNECTION_ARN <your_connection_arn>
     pulumi config set --secret SECRET_KEY <your_secret_key>
    

    Replace the placeholders with your actual values.

  4. Previewing the Deployment:

    Preview the changes that will be made to your infrastructure:

    Bash

    
     pulumi preview
    
  5. Deploying the Stack:

    Deploy your application and infrastructure:

    Bash

    
     pulumi up
    

    Confirm the deployment by typing yes when prompted.

Accessing the Deployed Application

Once the deployment is complete, you can find the App Runner service URL in the AWS console:

  1. Go to the AWS App Runner service.

  2. Click on your service name (story-generator-dev in this case).

  3. You'll find the "Default domain" on the service details page.

Alternatively, you can retrieve it from the Pulumi output if you exported it in your __main__.py:


pulumi stack output <output_variable_name> # Replace with the name of the output variable if you used pulumi.export

Conclusion

In this article, you successfully deployed your Story Generation App to AWS App Runner using Pulumi to manage our infrastructure as code. By containerizing your application with Docker and using App Runner, you can easily scale and manage your application in the cloud. Pulumi's Infrastructure as Code approach allows you to define your infrastructure in a repeatable and version-controlled manner, making it easier to manage and update your deployments.

0
Subscribe to my newsletter

Read articles from Iyanuoluwa Ajao directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Iyanuoluwa Ajao
Iyanuoluwa Ajao