Test Driving App Runner's Experimental AWS CDK Library

πŸš₯ What is AWS App Runner?

I discovered the relatively new App Runner service while testing deployments with AWS CDK. If App Runner is new to you, it’s possible to think of it as an AWS service abstraction level above ECS Fargate. It’s what AWS calls a fully managed service and will deploy your application as a container as well as handle auto scaling, SSL/TLS certificate management, CI/CD, and load balancing. With its auto scaling and load balancing features, it’s also possible to think of App Runner as a service that sits in between Lambda and ECS Fargate.

I decided to test drive App Runner using its experimental AWS CDK library. App Runner has many features, but I chose deployment with AWS CDK as the focus. I’m hoping it will provide a glimpse into the future of App Runner deployments.

πŸ”§ AWS CDK in Brief

Since this article is about using an experimental AWS CDK construct library, I might assume most readers are familiar with AWS CDK concepts, but just in case, AWS CDK is an infrastructure as code tool that transpiles AWS CDK code, written in a general-purpose programming language to JSON and then sends these assets to CloudFormation to deploy infrastructure and resources. In this article, I will be using TypeScript for my AWS CDK code, but AWS CDK also supports JavaScript, Python, Java, C#/.Net, and Go.

The AWS Cloud Development Kit Library contains constructs for specific AWS resources, and these constructs may consist of three levels of abstraction from those resources: L1, L2, and L3.

L1 constructs map directly to how CloudFormation defines resources. L2 and L3 constructs are levels of abstraction above this and include best practices for creating resources and groups of resources with default settings and assets. Basically, some of the complex or repetitive work is handled by L2 and L3 constructs.

It’s possible to create your own constructs, so a custom L3 construct could enforce best practices for resources created all at once for specific deployments.

For my test drive, in addition to App Runner, I’m going to deploy infrastructure for networking and a database. In AWS CDK, code for related resources is organized into stacks, so I’ll have an App Runner stack, a DNS stack, a VPC stack, and so forth.

🚧 AWS::AppRunner Construct Libraries

There are two AWS CDK libraries for App Runner: a construct library with generated L1 constructs and an experimental construct library with higher level L2 constructs. As I mentioned above, I’ll be test driving App Runner with the constructs in the experimental library.

🚦 Service and source

There isn’t a lot to the App Runner experimental library at the moment. There are two constructs: Service and VpcConnector:

// AWS::AppRunner CloudFormation Resources:
export * from './service';
export * from './vpc-connector';

I’ll look at VpcConnector a bit later. For now, the construct Service is where we can begin defining how App Runner deploys our application. At the source property of the Service construct, we reach an intersection with what appears to be four directions. We can deploy from a GitHub repository, a private or public ECR repository, or from local assets:

/**
 * Represents the App Runner service source.
 */
export abstract class Source {
  /**
   * Source from the GitHub repository.
   */
  public static fromGitHub(props: GithubRepositoryProps): GithubSource {
    return new GithubSource(props);
  }

  /**
   * Source from the ECR repository.
   */
  public static fromEcr(props: EcrProps): EcrSource {
    return new EcrSource(props);
  }

  /**
   * Source from the ECR Public repository.
   */
  public static fromEcrPublic(props: EcrPublicProps): EcrPublicSource {
    return new EcrPublicSource(props);
  }

  /**
   * Source from local assets.
   */
  public static fromAsset(props: AssetProps): AssetSource {
    return new AssetSource(props);
  }

  /**
    * Called when the Job is initialized to allow this object to bind.
    */
  public abstract bind(scope: Construct): SourceConfig;
}

These avenues seem limiting. I don’t see GitLab, Docker Hub, GitHub Packages, and other container registry options. Also, when I look more closely at fromAsset, I see that this road also leads to ECR:

//...
  public bind(_scope: Construct): SourceConfig {
    return {
      imageRepository: {
        imageConfiguration: this.props.imageConfiguration,
        imageIdentifier: this.props.asset.imageUri,
        imageRepositoryType: ImageRepositoryType.ECR,
      },
      ecrRepository: this.props.asset.repository,
    };
  }
// ...

asset used here is an import from the aws-cdk-lib/aws-ecr-assets package, and the repository type is hard-coded to private ECR: ImageRepositoryType.ECR.

If you prefer to deploy from containers that you build and don’t use ECR, I guess the rest of this test drive is going to be a scenic backseat joyride for you.

Perhaps one day these properties will be consolidated with more options for version controlled, packaged, and local sources. Until then, as someone who doesn’t use ECR, I’m most curious about the fromGitHub route.

πŸš— fromGitHub

With Source.fromGitHub, it seems like I’ve reached cruising speed for my test drive. Here the fully managed features of App Runner really start to take over for a deployment, and I get to tell App Runner to take my code and go.

There are up to five properties I can use to configure an App Runner deployment from a GitHub source code repository:

/**
 * Properties of the Github repository for `Source.fromGitHub()`
 */
export interface GithubRepositoryProps {
  /**
   * The code configuration values. Will be ignored if configurationSource is `REPOSITORY`.
   * @default - no values will be passed. The `apprunner.yaml` from the github reopsitory will be used instead.
   */
  readonly codeConfigurationValues?: CodeConfigurationValues;

  /**
   * The source of the App Runner configuration.
   */
  readonly configurationSource: ConfigurationSourceType;

  /**
   * The location of the repository that contains the source code.
   */
  readonly repositoryUrl: string;

  /**
   * The branch name that represents a specific version for the repository.
   *
   * @default main
   */
  readonly branch?: string;

  /**
   * ARN of the connection to Github. Only required for Github source.
   */
  readonly connection: GitHubConnection;
}

First, let’s deal with the connection property. There’s currently no option to create and approve a connection to GitHub using AWS CDK or CloudFormation without creating a custom resource (more on custom resources later), and since this process requires UI approval in the App Runner Console anyway, I followed the guide in the AWS App Runner Workshop to create a connection in the Console. I’m going to use GitHub Actions to deploy, so I’ll create an environment secret for the connection’s ARN in my repository and reference it from there:

  - name: Deploy CDK stack
    working-directory: ./
    run: CONNECTION=${{ secrets.APP_RUNNER_CONNECTION }} npx cdk deploy --all --require-approval never

Next, the configurationSource property determines whether to use values defined in an App Runner configuration file, apprunner.yaml, or values defined by the optional codeConfigurationValues? property for deployment.

An App Runner configuration file includes additional build options, such as pre-build and post-build commands. Setting configurationSource to ConfigurationSourceType.REPOSITORY will tell the property to use an App Runner configuration file in your repo.

Maybe the next time I take App Runner for a spin, I’ll try using an App Runner configuration file and use the pre-build step to run unit tests similar to what I do with multi-stage builds in a Dockerfile.

For this test drive, however, since I want to work with TypeScript as much as I can, I’m going to set configurationSource to ConfigurationSourceType.API and add my deployment configurations to the codeConfigurationValues? property:

// ...
      source: AppRunnerAlpha.Source.fromGitHub({
        configurationSource: AppRunnerAlpha.ConfigurationSourceType.API,
        codeConfigurationValues: {
          runtime: AppRunnerAlpha.Runtime.GO_1,
          port: containerPort,
          environmentSecrets: {
            DSN: AppRunnerAlpha.Secret.fromSecretsManager(props.dbSecret),
          },
          buildCommand: "go build ./cmd/web/",
          startCommand: "./web",
        },
        connection: AppRunnerAlpha.GitHubConnection.fromConnectionArn(
          `${process.env.CONNECTION ?? ""}`,
        ),
        repositoryUrl,
        branch,
      }),
// ...

Once again, App Runner is fully managed, so the build process is now going to happen behind the scenes. I don’t have to worry about a container registry or repository, provisioning a pipeline or build instance, pulling images to production instances, and orchestration to get and keep my container in a running state. App Runner does all that.

When App Runner builds your image, there is a fee of $0.005 per build minute.

πŸš™ VpcConnector

The other construct in the App Runner experimental library is VpcConnector. If your application architecture includes resources in private subnets, you’ll need a VPC Connector for outgoing traffic from your App Runner application to resources there.

The app I tested was a web app backed by an RDS database, so I created a VPC Connector with my deployment:

// ...

    const vpcConnector = new AppRunnerAlpha.VpcConnector(this, "VpcConnector", {
      vpc: props.vpc,
      vpcSubnets: props.vpc.selectSubnets({ onePerAz: true }),
      vpcConnectorName: "VpcConnector",
    });

// ...

🏎 Behind the showroom curtain: custom domain names

Before writing any AWS CDK code, I took a quick peek at the App Runner API reference and the deployment process in the App Runner Console, and I noticed there’s an important feature in the App Runner API that’s missing in the CloudFormation resources: custom domain management.

If you’ve worked with AWS CDK (or CloudFormation) before, running into a limitation like this doesn’t mean you’ve run out of road, not if you have custom resources under your hood.

In a previous article, I wrote about custom resources so to paraphrase from there: a custom resource allows us to create our own customized provisioning logic using API calls, such as associateCustomDomain, describeCustomDomain, and disassociateCustomDomain from the App Runner API.

In AWS CDK, a custom resource executes using a special Lambda function that must handle create, update, and delete operations. These operations are what CloudFormation expects during provisioning.

To start, I figured I’d need a custom resource to handle the associateCustomDomain and disassociateCustomDomain App Runner API calls:

import { AppRunner, type AWSError } from "aws-sdk";
import { type PromiseResult } from "aws-sdk/lib/request";

const apprunner = new AppRunner();

const addCustomDomain = async (
  subdomain: string,
  serviceArn: string,
): Promise<PromiseResult<AppRunner.Types.AssociateCustomDomainResponse, AWSError>> =>
  await apprunner
    .associateCustomDomain({
      DomainName: subdomain,
      ServiceArn: serviceArn,
      EnableWWWSubdomain: false,
    })
    .promise();

const removeCustomDomain = (
  subdomain: string,
  serviceArn: string,
): Request<AppRunner.DisassociateCustomDomainResponse, AWSError> =>
  apprunner.disassociateCustomDomain({
    DomainName: subdomain,
    ServiceArn: serviceArn,
  });

export async function handler(event: any): Promise<any> {
  const { hostedZoneId, subdomain, serviceArn } = event.ResourceProperties;

  if (event.RequestType === "Delete") { // handle delete operations
    removeCustomDomain(subdomain, serviceArn);
    return;
  }

  await addCustomDomain(subdomain, serviceArn); // handle create and update operations
}

Now I’ll update my App Runner stack to use this custom resource:

// ...

export class AppRunnerStack extends Stack {
    // ...
  
    this.customDomain(subDomain, serv.serviceArn, props.hostedZone);
  }

  customDomain(subdomain: string, serviceArn: string, hostedZone: HostedZone): void {
    const provider = new custom_resources.Provider(this, "Provider", {
      onEventHandler: new aws_lambda_nodejs.NodejsFunction(this, "CustomDomain", {
        initialPolicy: [
          new PolicyStatement({
            actions: [
              "apprunner:AssociateCustomDomain",
              "apprunner:DisassociateCustomDomain",
            ],
            resources: ["*"],
          }),
        ],
      }),
    });
    void new CustomResource(this, "CustomResource", {
      serviceToken: provider.serviceToken,
      properties: {
        subdomain,
        serviceArn,
        hostedZoneId: hostedZone.hostedZoneId,
      },
    });
  }
}

This deploys successfully, but I notice that my custom domain isn’t reachable, and in the Console under the Custom domains tab, its status is “Pending certificate DNS validation”.

I click through to my domain name in the Console, and I see there’s a Configure DNS section with two certificate validation records and a DNS target record that I need to add to my custom Route 53 hosted zone, similar to what’s described in the App Runner Documentation:

Configure DNS

So in my custom resource, I’m also going to need to loop through the API’s CustomDomains.CertificateValidationRecords and add them as CNAME’s to my custom hosted zone. For this update, I’ll use the Route 53 changeResourceRecordSets API call:

// ...

const describeCustomDomain = async (
  serviceArn: string,
): Promise<PromiseResult<AppRunner.Types.DescribeCustomDomainsResponse, AWSError>> =>
  await apprunner
    .describeCustomDomains({
      ServiceArn: serviceArn,
    })
    .promise();

const updateRecord = async (
  hostedZoneId: string,
  name: string,
  value: string,
): Promise<Route53.Types.ChangeResourceRecordSetsResponse> =>
  await route53
    .changeResourceRecordSets({
      HostedZoneId: hostedZoneId,
      ChangeBatch: {
        Changes: [
          {
            Action: changeAction,
            ResourceRecordSet: {
              Name: name,
              Type: certRecordType,
              TTL: certRecordTTL,
              ResourceRecords: [{ Value: value }],
            },
          },
        ],
      },
    })
    .promise();

export async function handler(event: any): Promise<any> {
  const { hostedZoneId, subdomain, serviceArn } = event.ResourceProperties;

  if (event.RequestType === "Delete") {
    removeCustomDomain(subdomain, serviceArn);
    return;
  }
  
  const domain = await addCustomDomain(subdomain, serviceArn);
  
  // Add the DNS target record
  await updateRecord(hostedZoneId, subdomain, domain.DNSTarget);
  
  // Add the certificate validation records
  const customDomain = await describeCustomDomain(serviceArn);
  const records = customDomain.CustomDomains.find(Boolean)?.CertificateValidationRecords;

  if (records !== undefined) {
    for (const record of records) {
      await updateRecord(hostedZoneId, record.Name!, record.Value!);
    }
  }
}

But there’s a problem here: I can add my custom domain immediately, but the certificate validation records need time to populate before I can add them, so these two operations must synchronize. A quick way to sync these operations is to use separate AWS CDK stacks, so I’ll create a new stack and custom resource for the certification validation actions:

import type * as AppRunnerAlpha from "@aws-cdk/aws-apprunner-alpha";
import {
  type App,
  aws_lambda_nodejs,
  custom_resources,
  CustomResource,
  Stack,
  type StackProps,
} from "aws-cdk-lib";
import { type HostedZone } from "aws-cdk-lib/aws-route53";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";

export interface AppRunnerCertStackProps extends StackProps {
  hostedZone: HostedZone;
  serv: AppRunnerAlpha.Service;
}

export class AppRunnerCertStack extends Stack {
  constructor(scope: App, id: string, props: AppRunnerCertStackProps) {
    super(scope, id, props);

    const subDomain = this.node.tryGetContext("subDomain") as string;

    this.certValidation(subDomain, props.serv, props.hostedZone);
  }

  certValidation(subdomain: string, service: AppRunnerAlpha.Service, hostedZone: HostedZone): void {
    const provider = new custom_resources.Provider(this, "Provider", {
      onEventHandler: new aws_lambda_nodejs.NodejsFunction(this, "CertValidation", {
        initialPolicy: [
          new PolicyStatement({
            actions: ["route53:changeResourceRecordSets", "apprunner:DescribeCustomDomains"],
            resources: ["*"],
          }),
        ],
      }),
    });
    void new CustomResource(this, "CustomResource", {
      serviceToken: provider.serviceToken,
      properties: {
        subdomain,
        serviceArn: service.serviceArn,
        hostedZoneId: hostedZone.hostedZoneId,
      },
    });
  }
}
import { AppRunner, type AWSError, Route53 } from "aws-sdk";
import { type PromiseResult } from "aws-sdk/lib/request";

const apprunner = new AppRunner();
const route53 = new Route53({ region: "us-east-1" });
const certRecordType: string = "CNAME";
const certRecordTTL: number = 300;
const changeAction: string = "UPSERT";

const describeCustomDomain = async (
  serviceArn: string,
): Promise<PromiseResult<AppRunner.Types.DescribeCustomDomainsResponse, AWSError>> =>
  await apprunner
    .describeCustomDomains({
      ServiceArn: serviceArn,
    })
    .promise();

const updateRecord = async (
  hostedZoneId: string,
  name: string,
  value: string,
): Promise<Route53.Types.ChangeResourceRecordSetsResponse> =>
  await route53
    .changeResourceRecordSets({
      HostedZoneId: hostedZoneId,
      ChangeBatch: {
        Changes: [
          {
            Action: changeAction,
            ResourceRecordSet: {
              Name: name,
              Type: certRecordType,
              TTL: certRecordTTL,
              ResourceRecords: [{ Value: value }],
            },
          },
        ],
      },
    })
    .promise();

export async function handler(event: any): Promise<any> {
  const { hostedZoneId, serviceArn } = event.ResourceProperties;

  if (event.RequestType === "Delete") {
    return;
  }

  const customDomain = await describeCustomDomain(serviceArn);
  const records = customDomain.CustomDomains.find(Boolean)?.CertificateValidationRecords;

  if (records !== undefined) {
    for (const record of records) {
      await updateRecord(hostedZoneId, record.Name!, record.Value!);
    }
  }
}

Now when I deploy, my custom domain is reachable on HTTPS (after about 15 minutes for me).

There are some more interesting things happening behind the scenes here. If I look at AWS Certificate Manager in the Console, I see that there are no certificates listed.

acm

App Runner is managing the certificate validation, and I really only need to add the DNS target records to my custom Route 53 hosted zone.

If you’re interested in looking at more AWS CDK code, I also created a GitHub Actions to destroy my App Runner deployment and created a DNS Delete Stack and custom resource for this workflow.

🏁 The End of the Road πŸ›‘

Overall, I feel this was an enjoyable test drive. App Runner is a promising service. It made many things easier, and the pricing is pretty good.

cost

In the future, I would like to see custom domain management added to CloudFormation and AWS CDK, but for today, I have no problem with the abstractions App Runner makes. A fully managed service means that I don’t need to see container images in repos or certificates in ACM. I’m comfortable with App Runner taking the wheel.