Post-Image

Trials and Tribulations +++ authors = [‘James MacGowan’] date = 2023-03-28T09:45:00Z draft = true image = “/images/posts/cut_costs_with_aws_app_runner/runner.jpg” summary = “A look at using Open ID Connect for cross-cloud authentication.” tags = [“aws”, “app runner”, “applications”, “hosting”, “start-up”] title = “Cut costs with AWS App Runner”

+++

Start Small

App Runner is AWS’ answer to Azure Web App, GCP Cloud Run, and GCP App Engine. It is an all;-in-one solution for basic web application hosting. A perfect good place to start for a start-up or a lightly used internal web application.

It is fairly new and is in undergoing a rapid rate of changes with new feature being added monthly. It is finally at a state where it can be used to cut costs compared to an ECS Fargate Service in behind an Application Load-Balancer.

It fits in between Lambda and ECS Fargate as far as performance and capabilities.

The benefit is that you save the cost of a dedicated application load-balancer. The limitations are that it does not support SSL (HTTPS) between the load-balancer and your container. you can’t run multiple containers, use external certificates, use websockets, customize the logging, or choose a size smaller then a 1vCPU. Auto-scaling comes built-in, but if you are scaling beyond 1-2 instances, you will want to stay on ECS Fargate as the cost will start to become higher then ECS. This is the similar to why you wouldn’t host your application in a Lambda function. The hourly running cost for lambda is far higher then App Runner or ECS Fargate.

If you have an existing CloudFormation template-based deployment, migrating to App Runner can be a bit of a problem. There is currently support for deploying the App Runner resource itself, but there isn’t support for custom domains and certificates. You can’t use the traditional methods of deploying a certificate through Certificate Manager. domain registration and certificate creation is handled within the App Runner service.

If order to deploy those resources from CloudFormation you will have to use a custom resource. Custom resources are one of the little-known workaround for the many limitations of CloudFormation. They are a do-it-yourself resource type that is backed by a Lambda function that you have to deploy and manage. They are similar to CloudFormation Macros, another workaround for CloudFormation limitations.

In the not-too-distant-future, this shouldn’t be be need and can be replaced, as they should add the additional AWS managed resource type for App Runner custom domains. For now, it is needed, if you want to manage your infrastructure from CloudFormation and use App Runner.

Terraform is not affected by this, as they already support the creation of the custom domain resource.

AppRunner Hosted Zones In order to create a Route53 record set using an alias record, you will need to know the hosted zone ID for the awsapprunner.com domain in your region of choice. This is crazy difficult to find. AWS doesn’t list these ID anywhere and there isn’t a way to look them up. The only way I have found to discover that the hosted zone ID is for your region is to go through the process of deploying an app runner service and creating the alias record set in the AWS web console. Then using AWSCLI to get the ID of the hosted zone used when they created the record set.

aws route53 list-resource-record-sets --hosted-zone-id <your-hosted-zone>

You should see something like the following in the command output. You will want to copy out the HostedZoneId and use it in the same fashion when creating the DNS record for your CloudFormation deployed App Runner service.

… { “Name”: “my.own.domain.”, “Type”: “A”, “AliasTarget”: { “HostedZoneId”: “Z02243383FTQ64HJ5772Q”, “DNSName”: “abc123.us-west-2.awsapprunner.com.”, “EvaluateTargetHealth”: true } }


Someone else has gone through the trouble of building a Lambda function that can backend the custom domain custom resource, it will not re-create the wheel. I will include a copy of it here in case it disappears from the internet. You can find the original copy at https://github.com/OblivionCloudControl/xebia-email-signature/blob/main/cloudformation/xebia-email-signature.yaml unless it has been removed.

You will want to centralize the deployment of any Lambda functions for CloudFormation custom resources and micros instead of including them in the template where they are used. This allows for re-use in other templates. So put the following Lambda function and it's IAM role into their own template to be deployed before the App Runner template.

below is the implementation of Custom::AppRunnerCustomDomain awaiting obsolescence until

AWS::AppRunner::CustomDomain is available. The implementation is maintained in a separate

python file.

AppRunnerCustomDomainProvider: Type: AWS::Lambda::Function Properties: Description: Custom::AppDomainCustomDomain provider Handler: index.handler MemorySize: 128 Timeout: 900 Role: !GetAtt CustomDomainLambdaRole.Arn Runtime: python3.9 Code: ZipFile: | ## Sourced from https://github.com/OblivionCloudControl/xebia-email-signature/blob/main/cloudformation/xebia-email-signature.yaml ## Created by Mark van Holsteijn and Steyn Huizinga import logging from urllib.request import urlopen, Request from time import sleep import boto3 import json from botocore.exceptions import ClientError class AppRunnerCustomDomainProvider: """ temporary implementation of AWS::AppRunner::CustomDomain """ def init(self, apprunner, sleep_time: float = 10.0): self.apprunner = apprunner self.sleep_time = sleep_time if not self.apprunner: self.apprunner = boto3.client(“apprunner”) @staticmethod def extract_attributes(response: dict) -> dict: set_identifier = response[“ServiceArn”].split("/")[-1] return { “DNSTarget”: response[“DNSTarget”], “ValidationResourceRecords”: [ { “Name”: r[“Name”], “Type”: r[“Type”], “ResourceRecords”: [r[“Value”]], “SetIdentifier”: set_identifier, “Weight”: “100”, “TTL”: “60”, } for r in response[“CustomDomain”][“CertificateValidationRecords”] ], } def create(self, request, response): """ associates a Custom Domain with an AppRunner service """ kwargs = { “ServiceArn”: request.get(“ResourceProperties”, {}).get(“ServiceArn”), “DomainName”: request.get(“ResourceProperties”, {}).get(“DomainName”), “EnableWWWSubdomain”: False, } _ = self.apprunner.associate_custom_domain(**kwargs) response[“PhysicalResourceId”] = “{ServiceArn},{DomainName}".format(**kwargs) self.read(request, response) def update(self, request, response): "”" updates an association of a Custom Domain with an AppRunner service """ kwargs = { “ServiceArn”: request.get(“ResourceProperties”, {}).get(“ServiceArn”), “DomainName”: request.get(“ResourceProperties”, {}).get(“DomainName”), } old_kwargs = { “ServiceArn”: request.get(“OldResourceProperties”, {}).get(“ServiceArn”), “DomainName”: request.get(“OldResourceProperties”, {}).get(“DomainName”), } if old_kwargs == kwargs: response[“Reason”] = “Nothing has changed” response[“PhysicalResourceId”] = request[“PhysicalResourceId”] self.read(request, response) return self.create(request, response) def read(self, request, response): """ reads the current custom domain and sets the resource attributes. """ service_arn = request.get(“ResourceProperties”, {}).get(“ServiceArn”) domain_name = request.get(“ResourceProperties”, {}).get(“DomainName”) attempt = 0 custom_domain = {} while custom_domain.get(“Status”, “creating”) == “creating” and attempt < 6: custom_domains = self.apprunner.describe_custom_domains( ServiceArn=service_arn ) custom_domain = next( filter( lambda r: r[“DomainName”] == domain_name and r[“Status”] != “creating”, custom_domains[“CustomDomains”], ), {}, ) if not custom_domain: attempt += 1 logging.info( f"sleeping {self.sleep_time}s to await the arrival of the validation records" ) sleep(self.sleep_time) if not custom_domain: response[“Status”] = “FAILED” response[“Reason”] = f"No custom domain found for {domain_name}" return custom_domains[“CustomDomain”] = custom_domain response[“Data”] = self.extract_attributes(custom_domains) response[“Status”] = “SUCCESS” def delete(self, request, response): """ disassociate a Custom Domain from an AppRunner service """ if not request[“PhysicalResourceId”].startswith(“arn:aws:apprunner”): response[“Reason”] = “ignoring failed create” response[“Status”] = “SUCCESS” return kwargs = { “ServiceArn”: request.get(“ResourceProperties”, {}).get(“ServiceArn”), “DomainName”: request.get(“ResourceProperties”, {}).get(“DomainName”), } try: _ = self.apprunner.disassociate_custom_domain(**kwargs) except ClientError as error: response[“Reason”] = “ignoring error to disassociate custom domain” logging.error("%s, %s", response[“Reason”], error) response[“Status”] = “SUCCESS” def handle(self, request): """ handles a cloudformation resource create, update or delete request """ response = { “StackId”: request[“StackId”], “RequestId”: request[“RequestId”], “LogicalResourceId”: request[“LogicalResourceId”], “Status”: “SUCCESS”, } if request.get(“PhysicalResourceId”, None): response[“PhysicalResourceId”] = request[“PhysicalResourceId”] try: request_type = request.get(“RequestType”) if request_type == “Create”: self.create(request, response) elif request_type == “Update”: self.update(request, response) elif request_type == “Delete”: self.delete(request, response) else: response[“Status”] = “FAILED” response[“Reason”] = f"unknown request type {request_type}" except Exception as error: response[“Status”] = “FAILED” response[“Reason”] = f"exception while processing {request_type}, {error}" logging.error("%s, %s", response[“Reason”], error) return response def post_response_to_cloudformation(url: str, response: dict): """ " posts the response message to CloudFormation """ data = json.dumps(response).encode(“utf-8”) logging.error("%s", data) put_request = Request( url=url, data=data, headers={“Content-Length”: len(data), “Content-Type”: “”}, method=“PUT”, ) urlopen(put_request) def handler(request, _): provider = AppRunnerCustomDomainProvider(boto3.client(“apprunner”)) post_response_to_cloudformation(request[“ResponseURL”], provider.handle(request))

CustomDomainLambdaRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: ‘2012-10-17’ Statement: - Action: - sts:AssumeRole Effect: Allow Principal: Service: - lambda.amazonaws.com Policies: - PolicyName: AssociateCustomDomainsPermission PolicyDocument: Version: ‘2012-10-17’ Statement: - Effect: Allow Action: - apprunner:AssociateCustomDomain - apprunner:DisassociateCustomDomain - apprunner:DescribeCustomDomains Resource: - ‘*’

    - PolicyName: WriteToLogGroupPermission
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Action:
              - logs:CreateLogStream
              - logs:PutLogEvents
              - logs:CreateLogGroup
            Resource:
              - '*'

Outputs: AppRunnerCustomDomainProvider: Description Identifier of the Lambda function that will need to be referenced in the Custom::AppRunnerCustomDomain resource. Value: !Ref AppRunnerCustomDomainProvider Export: Name: AppRunnerCustomDomainProvider


## Access Role
You will most likely want to grant custom permissions to databases, SSM parameters, or S3 buckets, you will probably want to create a custom access role.

The access role for your App Runner service will need to have the correct service provider name. Unlike other AWS services, App Runner has multiple sub-service providers and it is easy to miss this and just add apprunner.amazonaws.com to the trust relationship / assume role policy document. The correct service provider name for the access role is build.apprunner.amazonaws.com.

BookstackRole: Type: AWS::IAM::Role

Properties:
  RoleName: BookstackAppRole
  Path: /
  AssumeRolePolicyDocument:
    Statement:
      -
        Effect: Allow
        Principal:
          Service:
            - build.apprunner.amazonaws.com
        Action:
          - sts:AssumeRole

BookstackPolicy: Type: AWS::IAM::Policy

Properties:
  PolicyName: BookstackPolicy
  PolicyDocument:
    Version: 2012-10-17
    Statement:
      -
        Effect: Allow
        Action:
          - ecr:GetAuthorizationToken
          - ecr:BatchCheckLayerAvailability
          - ecr:GetDownloadUrlForLayer
          - ecr:BatchGetImage
          - ecr:DescribeImages
          - xray:PutTraceSegments
          - xray:PutTelemetryRecords
          - xray:GetSamplingRules
          - xray:GetSamplingTargets
          - xray:GetSamplingStatisticSummaries
          - cloudwatch:PutMetricData
          - ec2:DescribeVolumes
          - ec2:DescribeTags
          - ec2:DescribeNetworkInterfaces
          - ec2:DescribeVpcs
          - ec2:DescribeDhcpOptions
          - ec2:DescribeSubnets
          - ec2:DescribeSecurityGroups
          - ssm:GetParameters
        Resource: '*'
      -
        Effect: Allow
        Action:
          - logs:CreateLogGroup
          - logs:PutRetentionPolicy
        Resource:
          - arn:aws:logs:*:*:log-group:/aws/apprunner/*
      -
        Effect: Allow
        Action:
          - logs:CreateLogStream
          - logs:PutLogEvents
          - logs:DescribeLogStreams
        Resource:
          - arn:aws:logs:*:*:log-group:/aws/apprunner/*:log-stream:*
      -
        Effect: Allow
        Action:
          - events:PutRule
          - events:PutTargets
          - events:DeleteRule
          - events:RemoveTargets
          - events:DescribeRule
          - events:EnableRule
          - events:DisableRule
        Resource:
          - arn:aws:events:*:*:rule/AWSAppRunnerManagedRule*
      -
        Effect: Allow
        Action:
          - ec2:CreateNetworkInterface
        Resource: '*'
        Condition:
          ForAllValues:StringEquals:
            aws:TagKeys:
              - AWSAppRunnerManaged
      -
        Effect: Allow
        Action:
          - ec2:CreateTags
        Resource:
          - arn:aws:ec2:*:*:network-interface/*
        Condition:
          StringEquals:
            ec2:CreateAction:
              - CreateNetworkInterface
          StringLike:
            aws:RequestTag/AWSAppRunnerManaged: '*'
      -
        Effect: Allow
        Action:
          - ec2:CreateTags
        Resource: '*'
        Condition:
          'Null':
            ec2:ResourceTag/AWSAppRunnerManaged: 'false'
      -
        Effect: Allow
        Action:
          - aps:RemoteWrite
        Resource:
          - !Sub arn:aws:aps:${AWS::Region}:${AWS::AccountId}:workspace/*

  Roles:
    - !Ref BookstackRole

<br />

***

 

Share This Article

Comments