Python Service Deployment with the CDK, ECS and Fargate

Posted May 14, 2021 ‐ 12 min read

The Cloud Development Kit

Use a Well-Known Language

The Cloud Development Kit allows us to define our infrastructure by writing an application in a programming language such as TypeScript or Python, among others. We can use our preferred language to model infrastructure as well as take advantage of basic language constructs such as loops and conditions. By using an IDE to develop our CDK applications, we also get code completion and analysis. Although the framework is implemented in TypeScript, it provides the required language bindings to allow modeling infrastructure in other languages as well.

Provision with CloudFormation

Under the hood, the CDK delegates the provisioning step to the AWS CloudFormation service. As the CloudFormation service requires a declarative template as input, the CDK needs to translate our application, or actually a part of it called a stack, to a CloudFormation template. This process is called synthesis in CDK terminology.

The CDK Toolkit

One of the main components that ships with the CDK is the CDK Toolkit. This is a command-line tool that allows us to synthesize an application, deploy it using CloudFormation, and compare the current application model with the already provisioned infrastructure.

AWS Construct Library

In addition to the CLI tool, the CDK also provides the AWS Construct Library, a library of reusable cloud components. Constructs are the basic components of a CDK application and consist of three types or levels.

CDK Construct Levels

CFN Resources (L1 Constructs)

CFN Resources are automatically created from the CloudFormation specification and are a one-to-one representation of CloudFormation resources. An example of an L1 construct is a CfnQueue which models an SQS queue.

CDK/Curated Constructs (L2 Constructs)

Curated constructs offer sensible defaults as well as an improved API to model components. An example of a curated construct is Queue, which offers an API that is higher-level than its L1 counterpart. L2 constructs sometimes compose several resources together as well.

Patterns (L3 Constructs)

Patterns are recommended for common tasks. These high-level constructs bring together several resources and offer architecture implementations following best practices. In this article we will use a common example: a pattern which models an ECS service fronted by an application load balancer.

Application Composition and Workflow

Composition and Workflow

Every CDK application is composed of one or more stacks. Each stack is in turn composed of constructs encapsulating CloudFormation resources. The example application in the diagram above is composed of two stacks.

The BucketStack includes only a single construct, but it happens to translate to two resources: an S3 bucket resource as well as an associated bucket policy. The bucket policy has been added by the CDK because in our implementation we've decided to grant public read access to all objects in the bucket.

The EcsClusterStack includes two constructs: an ECS Cluster and its underlying VPC. Whilst the cluster corresponds to a single resource when synthesized to a CloudFormation stack, the VPC construct includes many resources such as the VPC resource itself, subnets, route tables, and potentially other resources such as gateways.

When we issue a cdk synth command on a particular stack, the toolkit produces a CloudFormation template for that stack. If only a single stack exists in our CDK app, there's no need to explicitly specify it as it will be automatically chosen by the toolkit.

Issuing a cdk deploy command will deploy the synthesized stack using the CloudFormation service. In fact, calling cdk deploy will trigger the required synthesis anyway.

Whenever we are making a change to one of our stacks, we can use the cdk diff command in order to review the proposed updates that our change would cause if we were to run cdk deploy.

CDK Usage

Install the CDK

To better understand the concepts above let's install and use the CDK to deploy a simple Python service to our provisioned ECS cluster.

As Node.js is required to run the CDK, let's install it first. I'm using macOS and the brew package manager:

$ brew install node

If you're using a different setup, then follow the instructions on the Node.js site to ensure you have the latest version.

Now that we have Node.js installed, install CDK globally using the node package manager:

$ npm install -g aws-cdk

Check that the CDK toolkit has been installed by running it with the version option.

$ cdk --version

Create the CDK Application

With the CDK installed, let’s create the CDK application. Create a new directory and cd into it:

$ mkdir cdk-ecs-example
$ cd cdk-ecs-example

Let's initialize a new CDK project by running the cdk init command with the Python language option. This would provide a good project scaffold to start with. List the files create after initializing the project.

$ cdk init --language python
$ ls -l

The init command bootstraps a new project with a git repository. A Python virtual environment is also set up in the .venv directory. Setting up a virtual environment is considered a best practice when developing Python projects.

If you’re not familiar with the concept of a virtual environment, then it basically ensures that the packages required for this project are stored locally and not within the global Python installation. The .venv folder contains the newly created virtual environment. The folder name is completely arbitrary, but it’s common to use venv or .venv as its name.

To activate the virtual environment, source the activate file in the virtual environment’s bin directory.

$ source .venv/bin/activate

The setup.py file contains metadata about the Python project, including required dependencies. We’ll be adding the additional requirements in this file under the install_requires section.

Run pip install to install the core requirements for this project. Let’s also follow up with the recommendation to upgrade the pip package itself.

$ pip install -r requirements.txt
$ pip install --upgrade pip

Let's have a look at an example cdk.json file.

{
  "app": "python3 app.py",
  "context": {
    "@aws-cdk/core:enableStackNameDuplicates": "true",
    "aws-cdk:enableDiffNoFail": "true",
    "@aws-cdk/core:stackRelativeExports": "true"
  }
}

The file configures the CDK for this particular project. For example, the app key tells the toolkit how to run the application in this project. In our example, python3 app.py is the command to run our python CDK application. The file also includes context values that are passed to the application during runtime for synthesis or deployment. In this application a few feature flags have been added to control the default behavior of the CDK.

The app.py module is the entry point for the application and is already initialized to include the core CDK library as well as a single stack. Recall that a CDK application could include more than one stack.

Let's change the construct ID of the stack to CdkEcsExample using CamelCase to be consistent with the naming CDK uses in general. The app.py should now look like this:

from aws_cdk import core

from cdk_ecs_example.cdk_ecs_example_stack import CdkEcsExampleStack

app = core.App()
CdkEcsExampleStack(app, "CdkEcsExample")

app.synth()

Let's next add the required dependencies for the project in the setup.py file under the install_requires section so that it looks like this:

install_requires = [
   "aws-cdk.core==1.92.0",
   "aws-cdk.aws-ec2==1.92.0",
   "aws-cdk.aws-ecs==1.92.0",
   "aws-cdk.aws-ecs-patterns==1.92.0",
],

As the CDK updates very frequently, your version is likely to be different. Run pip install -r requirements.txt again. This will install the new dependencies that are required to model a VPC as well as an ECS cluster and service. These libraries include several transitive dependencies such as libraries for S3, IAM, and CloudWatch.

Let's start by creating the VPC and ECS cluster. Instead of using the default setup, update the CdkEcsExampleStack code to include the following:

import aws_cdk.aws_ec2 as ec2
import aws_cdk.aws_ecs as ecs
from aws_cdk import core


class CdkEcsExampleStack(core.Stack):

    def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)
        vpc = ec2.Vpc(self, "EcsVpc", max_azs=2, nat_gateways=0)
        vpc.add_s3_endpoint("S3Endpoint")
        vpc.add_interface_endpoint("EcrDockerEndpoint", service=ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER)
        vpc.add_interface_endpoint("EcrEndpoint", service=ec2.InterfaceVpcEndpointAwsService.ECR)
        vpc.add_interface_endpoint("CloudWatchLogsEndpoint", service=ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS)
        cluster = ecs.Cluster(self, "EcsCluster", vpc=vpc)

This will create a VPC as shown in the following diagram.

VPC with Isolated Subnets and VPC Endpoints

The VPC spans two availability zones. Within each availability zone, the CDK creates a public and an isolated subnet. We’ve created VPC endpoints allowing the subnets to use the AWS service APIs without leaving the Amazon network, improving the security of our VPC setup. This also means that we do not need to provision NAT gateways as we'll be using our own service image which will be pulled from a private ECR repository.

Finally, we've created an ECS cluster referring to the VPC in the same stack. Note that we haven't added any capacity to the ECS cluster as we'll be using the Fargate compute engine instead of managing our own cluster infrastructure.

Run cdk synth on the command line. These few lines of code above required to model a VPC and ECS cluster synthesize to a verbose CloudFormation template. This is mostly because the VPC construct glues together many resources using tried-and-true patterns.

Let's run cdk deploy to provision the VPC and ECS cluster before continuing with the service.

$ cdk deploy

Scaffold the Service Application with FastAPI

With the underlying infrastructure provisioned let's create a bare-bones service that we could deploy to our cluster. To implement the service we'll also use Python language, and more specifically FastAPI, a modern asynchronous web framework.

Within our CDK application's root directory, let's create a directory called service.

$ mkdir service
$ cd service

Next, we'll add the required FastAPI dependency to a requirements.txt file within the service directory. We'll also use the uvicorn web server which will call into our application code. The requirements should be listed as follows:

fastapi==0.63.0
uvicorn==0.13.3

Our service scaffold will return a simple JSON response on the root path. We'll use this for testing, but also as the path that will be queried by the load-balancer. Create a new main.py module to include the following code:

from fastapi import FastAPI

app = FastAPI(title="Demo Service")


@app.get("/")
def demo():
    return {"message": "Just a demo!"}

Next, let's containerize the service with Docker. The following Dockerfile installs the required dependencies and runs uvicorn with the app instance of the FastAPI class instantiated in the main.py module.

FROM python:3.9-slim

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY main.py .

EXPOSE 8080

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

Let's build the image and test that it works before continuing with our CDK application:

$ docker build -t demo-service .
$ docker run -p 8080:8080 demo-service

Note that we've published port 8080 of the container to the host machine. We can use cURL to quickly test the endpoint.

$ curl -s http://localhost:8080 | jq
{
  "message": "Just a demo!"
}

With the service containerized, let's see how we could deploy it using the CDK. To the CdkEcsExampleStack import the ECS patterns construct library:

import aws_cdk.aws_ecs_patterns as ecs_patterns

Add the following code to the stack constructor:

task_definition = ecs.FargateTaskDefinition(self, "DemoServiceTask", family="DemoServiceTask")

image = ecs.ContainerImage.from_asset("service")

container = task_definition.add_container("app", image=image)
container.add_port_mappings(ecs.PortMapping(container_port=8080))

ecs_patterns.ApplicationLoadBalancedFargateService(self,
                                                   "DemoService",
                                                   cluster=cluster,
                                                   desired_count=2,
                                                   task_definition=task_definition)

Let's start with the last line of code: we use the ECS pattern construct to provision a fargate service fronted by a load balancer. We only need to specify the desired count of tasks running in the service and the task definition.

The task definition consists of a single container named app which maps port 8080 to the task port.

One of the interesting aspect of the CDK is the ability to build and deploy assets. An asset could be a directory containing Lambda code, or in our example, a Docker image built from the directory specified by the from_asset factory method.

Running cdk diff now to compare to the current stack with only the infrastructure provisioned shows what the ECS pattern encapsulates. A load balancer, an ECS service, task definition and task role are provisioned. In addition, a task execution role is configured with a policy allowing retrieval of images from an ECR repository where CDK assets are stored. As the tasks will be running in the isolated subnets, the VPC endpoint provide the ability to establish the secure connection to ECR. As the load balancer in this configuration is public by default, it will run in the public subnets. The provisioned security groups will allow traffic from the load balancer to port 8080 on the tasks in the isolated subnets.

Before we could deploy our stack with assets, we need to make sure our environment is bootstrapped. To do this, we need to issue the cdk bootstrap command.

This will provision the CdkToolkit CloudFormation stack in the environment which includes an S3 bucket to store assets such as Lambda code. As mentioned, it's an ECR repository that will store the Docker image, but the environment must be bootstrapped nevertheless.

With the Let's deploy the new service and load balancer by issuing a cdk deploy command. We can see that the first thing the CDK does is build the Docker image and pushes it to the ECR repository. The image path is later rendered into the ECS task definition. When the deployment is finished, you should see an output from the CloudFormation stack which includes the service URL which we can then use the query the service.

Given that there's cost associated with running a load-balancer as well as Fargate tasks, it's best to destroy the CDK stack when you're done testing out the demo service.

$ cdk destroy

Summary

In this article we've had a look at the CDK and reviewed some of its benefits. We've used it with the Python language to set up infrastructure and deploy a simple ECS service using the ECS patterns library.

Project code to accompany this article is available on GitHub.