4  Container infrastructure using AppRunner

It is now time to create an infrastructure in AWS to run container-based solutions!

There are actually many ways to run containers in AWS. Some may argue that there are way too many, which adds to confusion about what to choose.

We will start relatively simple, and use an AWS service called AppRunner. It is a service which is oriented towards running web applications in AWS, and if your use case fits well with what the service provides, it will be much easier than most other solutions. We will start with this service and see how it works. Later, when we want to explore building other services with more control of what we set up, we will try other approaches as well.

4.1 What are containerised applications?

Containers in computer infrastructure and software terms are a way to package applications or software solutions with its dependencies in a way that is portable and (relatively) easy to manage. It has become a popular way to deploy and manage various types of applications. We will not dive into details about creating, building, and using containers. There are many other resources that cover that extensively. Here are a few links to check out for information about containers (and Docker, which popularised the concept):

  • https://techgenix.com/getting-started-with-containers/
  • https://www.docker.com/get-started
  • https://www.youtube.com/watch?v=fqMOX6JJhGo
  • https://www.youtube.com/watch?v=pTFZFxd4hOI

4.2 Containers in AWS

There are actually many ways to run containers in AWS. Some may argue that there are way too many, which adds to confusion about what to choose.

When we use AppRunner, a lot of nitty-gritty details are kind of hidden from us, to allow to focus on shipping the application to a large extent. However, it also has made some opinionated choices and set some restrictions to make that simpler. It is also more restricted in that AWS does not support it in all regions it support some other container-based solutions.

While there are many services services in AWS that allows running containers, the major services that are focused on running containers in general are Elastic Container Services (ECS) and Elastic Kubernetes Services (EKS). ECS is more flexible than AppRunner, but with some additional complexity for the infrastructure comes with that. EKS is even more flexible than ECS, but also comes with more infrastructure complexity.

We will start with AppRunner here, and later continue with ECS.

4.3 Goals

Before we begin, let us define what we should accomplish. We will keep it relatively simple, but still not too simple. After that, we will refine our solution to make it better in various ways. Along the way, we should pick up some good practices and feature of the AWS CDK!

  • Expose an endpoint for a web application for HTTP traffic from internet.
  • The web application shall run in a container.
  • The container itself shall not be directly reachable from internet.
  • We should be able to have a service set up so that containers will automatically be started if needed.
  • We should be able to build our custom solution for this web application.
  • We should be able to get container images from some kind of registry for containers.
  • We do not care about managing the underlying server infrastructure that runs the containers.

We will not fix all these points in one go, but iterate on these points. By listing a few points for our goals, we have something to focus on. Once we are done with these, we can refine even further.

The application solution is initially going to be a demo application called Hello App Runner. This is a straightforward solution for us to test that we have something working, as there are pre-built container images to use.

4.4 Initialize our project

Similar to what we did for our first project, we create a new project and use the uv tool to initialialise it. We also add the aws-cdk-lib package.

mkdir my-app-runner
cd my-app-runner
uv init
uv add aws-cdk-lib aws-cdk.aws-apprunner-alpha

In addition, we can rename hello.py created by uv init to app-runner.py. You can pick another name if you wish, just remember to update the cdk.json file with that name.

As before, we want a cdk.json file to be created as well.

{
    "app": "uv run app-runner.py"
}

We have added another package here in addition to aws-cdk-lib, which is part of AWS CDK, but not yet been included in the main package. This is called aws-cdk.aws-apprunner-alpha. Packages like this have interfaces and APIs that potentially may change in the future. When the AWS CDK team introduces features for a service they have not yet finalized, that can be introduced as an “alpha” package. Once enough people have used the package, it will be finalized and included in the main package.

As in the previous project, we will start with a single file for our infrastructure.

So let us start from the top, in our main program file, app-runner.py. Our starting point will be quite similar to our previous project, with an App and a Stack. In this case we will not need a VPC, at least not initially.

We have also two additional changes compared to before. First is that we import the modeul from the separate AppRunner package. Second is that we use a separate parameter for the stack name when we create the stack.

If we do not explicitly specify a stack name, the AWS CDK will use the id of the stack as the stack name. However, if we specify the parameter stack_name, that will be used instead. This allows us to create the same stack content multiple times, for example.

import os
import aws_cdk as cdk
from aws_cdk import (
    aws_apprunner_alpha as apprunner,
)

app = cdk.App()
env = cdk.Environment(
    account=os.environ.get("CDK_DEFAULT_ACCOUNT"),
    region=os.environ.get("CDK_DEFAULT_REGION"),
)
my_stack_name = "my-app-stack"
stack = cdk.Stack(app, "app-runner-stack", stack_name=my_stack_name, env=env)

app.synth()

The code here is pretty much our boilerplate before we can add the actual infrastructure we want to put in place.

4.5 Setting up App Runner

A key concept in AppRunner is that of a Service. An AppRUnner service is the running instance of your application, with properties describing how it should scale, what its source is, and how it is accessed. The Source type is another key concept, and can be one of two types - a container imgae, or source code in a repository. We will use the container image for now.

A basic service definition, using a container image source looks like this:

app_source = apprunner.Source.from_ecr_public(
    image_configuration=apprunner.ImageConfiguration(port=8000),
    image_identifier="public.ecr.aws/aws-containers/hello-app-runner:latest",
)
service = apprunner.Service(stack, "app-runner-service", source=app_source)
cdk.CfnOutput(stack, "app-runner-service-url", value=service.service_url)

The first part here is the definition of the source. In this case, we fetch a container image from ECR Public. This is a public container image registry that can be used by anyone, similar to DockerHub. Many popular container images are available in ECR Public as well as DockerHub. You can also use ECR private repositories as a source for AppRunner services, but you cannot use DockerHub. It is either ECR or ECR Public if you want to fetch container images.

The image_configuration parameter allows some resatricted configuration of the image, which is a TCP port, environment variables and secrets, and a start command. All of these have default values. In our case here we only specify the port to use for our application.

The Serviceclass will take the source as input and create the service itself. Details about logging, scaling, CPU, memeory, etc. have default values and only need to be specified if the defaults are not good enough. We just want to get our application up and running quickly to then iterate on our infrastructure. By default it is also publically accessible.

Finally we will add a CfnOutput to our stack, to show the URL of the service when we deploy the stack. Stack outputs are useful when you want to share information between stacks, or otherwise expose certain data from the stack, which only may be available until after deployment of the stack.

Our complete code thus looks like this:

import os
import aws_cdk as cdk
from aws_cdk import (
    aws_apprunner_alpha as apprunner,
)

app = cdk.App()
env = cdk.Environment(
    account=os.environ.get("CDK_DEFAULT_ACCOUNT"),
    region=os.environ.get("CDK_DEFAULT_REGION"),
)
my_stack_name = "my-app-stack"
stack = cdk.Stack(app, "app-runner-stack", stack_name=my_stack_name, env=env)

app_source = apprunner.Source.from_ecr_public(
    image_configuration=apprunner.ImageConfiguration(port=8000),
    image_identifier="public.ecr.aws/aws-containers/hello-app-runner:latest",
)
service = apprunner.Service(stack, "app-runner-service", source=app_source)
cdk.CfnOutput(stack, "app-runner-service-url", value=service.service_url)
app.synth()

Let us use the command cdk diff command in this case. This is a command that shows the difference between the infrastructure that our code generates, compared to what is already deployed. Since we have not yet deployed the stack, we will see all the resources that it will create.

 cdk diff
Stack app-runner-stack (my-app-stack)
IAM Statement Changes
┌───┬─────────────────────────────────────┬────────┬────────────────┬─────────────────────────────────────┬───────────┐
   │ Resource                            │ Effect │ Action         │ Principal                           │ Condition │
├───┼─────────────────────────────────────┼────────┼────────────────┼─────────────────────────────────────┼───────────┤
 + │ ${app-runner-service/InstanceRole.Arn │ Allow  │ sts:AssumeRole │ Service:tasks.apprunner.amazonaws.c │           │
   │ }                                   │        │                │ om                                  │           │
└───┴─────────────────────────────────────┴────────┴────────────────┴─────────────────────────────────────┴───────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Parameters
[+] Parameter BootstrapVersion BootstrapVersion: {"Type":"AWS::SSM::Parameter::Value<String>","Default":"/cdk-bootstrap/hnb659fds/version","Description":"Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"}

Resources
[+] AWS::IAM::Role app-runner-service/InstanceRole apprunnerserviceInstanceRole1527418E
[+] AWS::AppRunner::Service app-runner-service apprunnerserviceA852BA10

Outputs
[+] Output app-runner-service-url apprunnerserviceurl: {"Value":{"Fn::GetAtt":["apprunnerserviceA852BA10","ServiceUrl"]}}


  Number of stacks with differences: 1

We see that in addition the the AppRunner service, it will create an IAM Role. This is quite common with AWS CDK, in many cases it will create a default IAM role for you, to set some suitable permissions that would be needed anyway. This can often be helpful, in particular in the beginning when you try to set up some infrasatructure. Later though, it can in many cases be more beneficial to define these yourself, typically when you learn more about your use case and the requirements.

You can run cdk deploy to deploy the application. It will take a couple of minutes to complete. You will the a URL that you can use to access the service through a web browser:

AppRunner page

We have something running!

If we login to the AWS Console and go to the AppRunner service, we can also see that we have something running there:

AppRunner page

We can click on the service to get more detailed information, which include various information about the service, logging of events, including service and application logs. There are also metrics for monitoring, under the “Metrics” tab.

AppRunner page

If we go to the “Configuration” tab, we can see some of the details of the available configuration. For most of that we have just used the default values, except the port number.

AppRunner page

We can see that we use one virtual CPU (vCPU) and 2 GB RAM memory. The concurrency setting is 100, which means that it expects one instance of our container to handle up to 100 requests at the same time. The number of containers can scale between 1 and 25.

4.6 Changing the default values

We most likely will want to change the CPU and memory requirements of our service, and probably also how it scales. This is something that will be different for each service. So let us change these settings!

We will be charged for the CPU and memory used, and multiplied by the number of active instances. In most regions where AppRunner is available, the current pricing is $0.064 per vCPU-hour, and $0.007 per GB-hour. So with the default setting, if one instance is running for 1 hour, it will cost $0.064 + $0.014 = $0.078. Continuously running the same application with 1 instance for 1 month would cost $0.071 * 30 * 24 = $56.16.

We can lower the settings a bit, in case we would forget about it. With 0.25 vCPU and 1 GB memory it would cost $0.023 * 30 * 24 = $16.56 for a month.

Also, we can reduce the number of instances it could scale up to from the default of 25 to 10. Adding these new settings to the Service resource, we have the following update to the code:

auto_scaling_config = apprunner.AutoScalingConfiguration(
    stack,
    "autoscaling-configuration",
    min_size=1,
    max_size=10,
    max_concurrency=40,
)

service = apprunner.Service(
    stack,
    "app-runner-service",
    source=app_source,
    cpu=apprunner.Cpu.QUARTER_VCPU,
    memory=apprunner.Memory.ONE_GB,
    auto_scaling_configuration=auto_scaling_config,
)

If we run a cdk diff we can see what should be deployed, if you deploy it from scratch. If you have just changed the previous deployment, you will see that the Service reosurce will be modified, and a new AutoScalingConfiguration resource will be added:

 cdk diff
Stack app-runner-stack (my-app-stack)
IAM Statement Changes
┌───┬─────────────────────────────────────┬────────┬────────────────┬─────────────────────────────────────┬───────────┐
   │ Resource                            │ Effect │ Action         │ Principal                           │ Condition │
├───┼─────────────────────────────────────┼────────┼────────────────┼─────────────────────────────────────┼───────────┤
 + │ ${app-runner-service/InstanceRole.Arn │ Allow  │ sts:AssumeRole │ Service:tasks.apprunner.amazonaws.c │           │
   │ }                                   │        │                │ om                                  │           │
└───┴─────────────────────────────────────┴────────┴────────────────┴─────────────────────────────────────┴───────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Parameters
[+] Parameter BootstrapVersion BootstrapVersion: {"Type":"AWS::SSM::Parameter::Value<String>","Default":"/cdk-bootstrap/hnb659fds/version","Description":"Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"}

Resources
[+] AWS::AppRunner::AutoScalingConfiguration autoscaling-configuration autoscalingconfigurationB226C248
[+] AWS::IAM::Role app-runner-service/InstanceRole apprunnerserviceinstancerole1527418E
[+] AWS::AppRunner::Service app-runner-service apprunnerserviceA852BA10

Outputs
[+] Output app-runner-service-url apprunnerserviceurl: {"Value":{"Fn::GetAtt":["apprunnerserviceA852BA10","ServiceUrl"]}}


  Number of stacks with differences: 1

Let us deploy this again with cdk deploy.

After successful deployment, we can see in the AWS Console that the configuration reflects our code update:

AppRunner config

4.7 AWS permissions for the app

When we deployed the stack, we had an AWS::IAM::Role resource deployed. This is an IAM role which defines the permissions our application has in relation to AWS resources. We can find the default created IAM role under IAM among the AWS services. There we can find an IAM role with a generated name matching our deployment, for example:

AppRunner IAM role

The trust relationship here gives the part of the AppRunner service that handles running tasks, i.e. the configured instances of the app, and what AWS permissions it will have.

We can create an entirely new role for that ourself, or we can also simply add the additional permissions we want. Let us make a simple example for that, setting permissions go get data from S3 buckets!

We need to define an IAM policy statement, which will say which operation(s) we will allow, and for what. First we need to update our imports to include IAM:

from aws_cdk import (
    aws_apprunner_alpha as apprunner,
    aws_iam as iam,
)

We add the policy statement and then add it to the role of the service, see also documentation for PolicyStatement here:

read_s3_policy = iam.PolicyStatement(actions=["s3:GetObject"], resources=["*"])
service.add_to_role_policy(read_s3_policy)

If we run cdk diff nopw we should get something like this:

 cdk diff

Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Stack AppRunnerStack (MyAppStack)
IAM Statement Changes
┌───┬──────────┬────────┬──────────────┬──────────────────────────────────────┬───────────┐
   │ Resource │ Effect │ Action       │ Principal                            │ Condition │
├───┼──────────┼────────┼──────────────┼──────────────────────────────────────┼───────────┤
 + │ *        │ Allow  │ s3:GetObject │ AWS:${app-runner-service/InstanceRole} │           │
└───┴──────────┴────────┴──────────────┴──────────────────────────────────────┴───────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Resources
[+] AWS::IAM::Policy app-runner-service/InstanceRole/DefaultPolicy apprunnerserviceinstanceRoleDefaultPolicy01AB01BA


  Number of stacks with differences: 1

We can deploy that and the permissions for our IAM role should then be updated to look like this:

AppRunner IAM role permissions

Success!

4.8 Connecting to a VPC

By default, if you use the AppRunner service is running in some AWS-managed infrastructure, and it does not have network access to resources that you may be running in your own AWS account networks, e.g. database instances, EC2 virtual machines, ECS/EKS clusters, etc. If you want your AppRunner service to be able to access resources in your own AWS account, you need to add a VPC Connector. This is essentially an entry point to a VPC of yours for the AppRunner service you are running.

While our example application do not need to access anything in a VPC, we can add that as an example.

There are four steps needed to do that:

  1. Import the aws_ec2 module
  2. Lookup an existing VPC in your account
  3. Create a VPC connector, referencing the VPC
  4. Associate the VPC connector to the AppRunner service

The import:

from aws_cdk import (
    aws_apprunner_alpha as apprunner,
    aws_ec2 as ec2,
    aws_iam as iam,
)

The lookup:

vpc = ec2.Vpc.from_lookup(stack, "my-vpc", is_default=True)

The VPC connector:

vpc_connector = apprunner.VpcConnector(
    stack,
    "vpc-connector",
    vpc=vpc,
    vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PUBLIC),
)

The association:

service = apprunner.Service(
    stack,
    "app-runner-service",
    source=app_source,
    cpu=apprunner.Cpu.QUARTER_VCPU,
    memory=apprunner.Memory.ONE_GB,
    auto_scaling_configuration=auto_scaling_config,
    vpc_connector=vpc_connector,
)

In our example here, we used the default VPC and choose public subnets there. For an actual real-life VPC, you may likely want to refer to private subnets instead.

When you deploy these changes, you will likely also notice that deployment takes longer time than before.

4.9 Wrapping up

The final version of our code will look something like this below here, with all the changes we made. It is relatively short code, since the AppRunner service hides a lot of details around infrastructure for us, and we can be productive relatively easily, but with multiple constraints.

import os
import aws_cdk as cdk
from aws_cdk import (
    aws_apprunner_alpha as apprunner,
    aws_ec2 as ec2,
    aws_iam as iam,
)

app = cdk.App()
env = cdk.Environment(
    account=os.environ.get("CDK_DEFAULT_ACCOUNT"),
    region=os.environ.get("CDK_DEFAULT_REGION"),
)
my_stack_name = "my-app-stack"
stack = cdk.Stack(app, "app-runner-stack", stack_name=my_stack_name, env=env)

vpc = ec2.Vpc.from_lookup(stack, "my-vpc", is_default=True)

app_source = apprunner.Source.from_ecr_public(
    image_configuration=apprunner.ImageConfiguration(
        port=8000,
    ),
    image_identifier="public.ecr.aws/aws-containers/hello-app-runner:latest",
)

auto_scaling_config = apprunner.AutoScalingConfiguration(
    stack,
    "autoscaling-configuration",
    min_size=1,
    max_size=10,
    max_concurrency=40,
)

vpc_connector = apprunner.VpcConnector(
    stack,
    "vpc-connector",
    vpc=vpc,
    vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PUBLIC),
)

service = apprunner.Service(
    stack,
    "app-runner-service",
    source=app_source,
    cpu=apprunner.Cpu.QUARTER_VCPU,
    memory=apprunner.Memory.ONE_GB,
    auto_scaling_configuration=auto_scaling_config,
    vpc_connector=vpc_connector,
)

read_s3_policy = iam.PolicyStatement(actions=["s3:GetObject"], resources=["*"])
service.add_to_role_policy(read_s3_policy)

cdk.CfnOutput(stack, "app-runner-service-url", value=service.service_url)
app.synth()

However, what we havce deployed so far is just a public web endpoint, without any backend resources like a database or similar.

If you are taking a break now (you have earned it, good work!), do not forget that it might be good to destroy the resources we have created now, with the cdk destroy command. It will only take a couple of minutes extra.

Of the goals we stated earlier, we can see which we have accomplished so far:

  • Expose an endpoint for a web application for HTTP traffic from internet.
  • The web application shall run in a container.
  • The container itself shall not be directly reachable from internet.
  • We should be able to have a service set up so that containers will automatically be started if needed.
  • We should be able to build our custom solution for this web application.
  • We should be able to get container images from some kind of registry for containers.
  • We do not care about managing the underlying server infrastructure that runs the containers.

Technically, we have fulfilled all of these, but many of these are features built-in to the service of AppRunner. We did not do much work ourself to accomplish that. AppRunner handles distributing traffic to our web application to different container instances, and does a lot of infrastructure work for us. However, there are many things we cannot control with AppRunner, so we have to decide whether these constraints are ok, of if we need that additional control.

Once we go to other types of container services, like Elastic Container Service (ECS), we will see that we have more control of what is done, but also requires more work from our side.

This is what we will start to explore in the next chapter.