ドメイン及びIPアドレス制限WAF付きのApp RunnerをCloudFormationで実装してみた

2023.12.23

こんにちは、つくぼし(tsukuboshi0755)です!

ここ最近App Runnerが徐々にアップデートされ、ECS Fargateの代替としてかなり使い勝手が良くなってきています。

今回は、ドメインとIPアドレスによるアクセス制限ルールを持つWAFをアタッチしたApp RunnerをCloudFormationで構築し、利用する方法を紹介します!

構成

今回作成する構成図は以下になります。

赤枠のWAF及びApp RunnerがCloudFormationでデプロイされるリソースになります。

WAFのルールとしては、以下の2種類を使用します。

  • ドメイン制限ルール:カスタムドメインによるアクセスのみを許可し、App Runnerデフォルトドメインによるアクセスを禁止
  • IPアドレス制限ルール:ホワイトリストとして特定IPアドレスからのアクセスのみ許可し、それ以外のIPアドレスからのアクセスを禁止

なおECRプライベートリポジトリとコンテナイメージ、及びRoute 53パブリックホストゾーンについては、今回のCloudFormationとは別で用意する必要があるためご注意ください。

テンプレート

全体のコードは以下の通りです。

CloudFormationコード

template.yaml

AWSTemplateFormatVersion: '2010-09-09'

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Parameters:
          - SysName
          - Env
          - ImageUri
          - ServiceCPU
          - ServiceMemory
          - CustomDomain
          - AllowAddresses

Parameters:
  SysName:
    Type: String
    Default: 'cm'
    Description: 'System name for this stack.'
  Env:
    Type: String
    Default: 'dev'
    AllowedValues:
      - 'prd'
      - 'stg'
      - 'dev'
    Description: 'Environment for this stack.'
  ImageUri:
    Type: String
    AllowedPattern: '([0-9]{12}.dkr.ecr.[a-z\-]+-[0-9]{1}.amazonaws.com\/.*)|(^public\.ecr\.aws\/.+\/.+)'
    Default: '111111111111.dkr.ecr.ap-northeast-1.amazonaws.com/cm-dev-ecr-repo:latest'
    Description: 'Image uri stored in ECR.'
  ServiceCPU:
    Type: String
    AllowedPattern: '256|512|1024|2048|4096|(0.25|0.5|1|2|4) vCPU'
    AllowedValues:
      - 0.25 vCPU
      - 0.5 vCPU
      - 1 vCPU
      - 2 vCPU
      - 4 vCPU
    Default: '1 vCPU'
    Description: 'The number of vCPUs to allocate to the container.'
  ServiceMemory:
    Type: String
    AllowedPattern: "512|1024|2048|3072|4096|6144|8192|10240|12288|(0.5|1|2|3|4|6|8|10|12) GB"
    AllowedValues:
      - 0.5 GB
      - 1 GB
      - 2 GB
      - 3 GB
      - 4 GB
      - 6 GB
      - 8 GB
      - 10 GB
      - 12 GB
    Default: '2 GB'
    Description: 'The amount of memory (in GB) to allocate to the container.'
  CustomDomain:
    Type: String
    Default: 'example.com'
    Description: 'Custom domain to access an application.'
  AllowAddresses:
    Type: CommaDelimitedList
    Default: "1.1.1.1/32"
    Description: 'IPv4 list to allow access.'

Resources:
  AccessRoleforAppRunner:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SysName}-${Env}-apprunner-access-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: build.apprunner.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess

  InstanceRoleforAppRunner:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SysName}-${Env}-apprunner-instance-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: tasks.apprunner.amazonaws.com
            Action: sts:AssumeRole

  AppRunnerService:
    Type: AWS::AppRunner::Service
    Properties:
      ServiceName: !Sub ${SysName}-${Env}-apprunner-srv
      AutoScalingConfigurationArn: !GetAtt AppRunnerServiceASConfig.AutoScalingConfigurationArn
      HealthCheckConfiguration:
        Protocol: "HTTP"
        Path: "/"
      InstanceConfiguration:
        Cpu: !Ref ServiceCPU
        Memory: !Ref ServiceMemory
        InstanceRoleArn: !GetAtt InstanceRoleforAppRunner.Arn
      SourceConfiguration:
        AuthenticationConfiguration:
          AccessRoleArn: !GetAtt AccessRoleforAppRunner.Arn
        AutoDeploymentsEnabled: true
        ImageRepository:
          ImageIdentifier: !Ref ImageUri
          ImageRepositoryType: ECR
          ImageConfiguration:
            Port: 80

  AppRunnerServiceASConfig:
    Type: AWS::AppRunner::AutoScalingConfiguration
    Properties:
      AutoScalingConfigurationName: !Sub ${SysName}-${Env}-apprunner-as-config
      MaxConcurrency: 100
      MaxSize: 25
      MinSize: 1

  WebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub ${SysName}-${Env}-WebACL
      Scope: REGIONAL
      DefaultAction:
        Block: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: !Sub ${SysName}-${Env}-WebACL
      Rules:
        - Name: 'allow-only-custom-domain'
          Priority: 0
          Action:
            Block: {}
          Statement:
            NotStatement:
              Statement:
                ByteMatchStatement:
                  FieldToMatch:
                    SingleHeader:
                      Name: 'host'
                  PositionalConstraint: 'EXACTLY'
                  SearchString: !Ref CustomDomain
                  TextTransformations:
                    - Priority: 0
                      Type: 'LOWERCASE'
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: 'allow-only-custom-domain'
        - Name: 'ip-addresses-white-list'
          Priority: 1
          Action:
            Allow: {}
          Statement:
            IPSetReferenceStatement:
              Arn: !GetAtt WAFIPSet.Arn
          VisibilityConfig:
            SampledRequestsEnabled: true
            MetricName: 'ip-addresses-white-list'
            CloudWatchMetricsEnabled: true

  WAFIPSet:
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: IPAllowLists
      Scope: REGIONAL
      IPAddressVersion: IPV4
      Addresses: !Ref AllowAddresses

  WebACLAssociation:
    Type: AWS::WAFv2::WebACLAssociation
    Properties:
      WebACLArn: !GetAtt WebACL.Arn
      ResourceArn: !GetAtt AppRunnerService.ServiceArn

  LogGroupforWAF:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub aws-waf-logs-${SysName}-${Env}
      RetentionInDays: 365

  ResourcePolicyforWAFToCW:
    Type: AWS::Logs::ResourcePolicy
    Properties:
      PolicyName: !Sub WAFToCWLogsPolicy-${SysName}-${Env}
      PolicyDocument:
        Fn::Sub:
          - |
            {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Principal": {
                    "Service": "delivery.logs.amazonaws.com"
                  },
                  "Action": [
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                  ],
                  "Resource": "${CloudWatchLogsLogGroupArn}",
                  "Condition": {
                    "StringEquals": {
                      "aws:SourceAccount": ${AWS::AccountId}
                    },
                    "ArnLike": {
                      "aws:SourceArn": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
                    }
                  }
                }
              ]
            }
          - CloudWatchLogsLogGroupArn: !GetAtt LogGroupforWAF.Arn

  WAFLogConfig:
    Type: AWS::WAFv2::LoggingConfiguration
    Properties:
      LogDestinationConfigs:
        - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:aws-waf-logs-${SysName}-${Env}
      ResourceArn: !GetAtt WebACL.Arn

Outputs:
  DefaultDomain:
    Value: !GetAtt AppRunnerService.ServiceUrl
    Description: 'Default Domain to access an application.'
  CustomDomain:
    Value: !Ref CustomDomain
    Description: 'Custom Domain to access an application.'

以下より、本テンプレートで作成される各リソースについて説明します。

App Runner用サービスロール

  AccessRoleforAppRunner:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SysName}-${Env}-apprunner-access-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: build.apprunner.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess

  InstanceRoleforAppRunner:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SysName}-${Env}-apprunner-instance-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: tasks.apprunner.amazonaws.com
            Action: sts:AssumeRole

ECRイメージにアクセスするためのアクセスロールと、コンテナインスタンスが他のAWSサービスにアクセスするためのインスタンスロールを作成します。

以下の公式ドキュメントに基づき、必要な権限をアタッチします。

App Runner オートスケーリング設定

  AppRunnerServiceASConfig:
    Type: AWS::AppRunner::AutoScalingConfiguration
    Properties:
      AutoScalingConfigurationName: !Sub ${SysName}-${Env}-apprunner-as-config
      MaxConcurrency: 100
      MaxSize: 25
      MinSize: 1

App Runnerで稼働するコンテナインスタンスをオートスケーリングするための設定を作成します。

MaxConcurrencyには、同時に実行できるリクエストの最大数を指定します。このリクエスト数を超えると、コンテナインスタンスがスケールアウトします。

MaxSizeには、コンテナインスタンス数の最大値を指定します。

MinSizeには、コンテナインスタンス数の最小値を指定します。

App Runnerサービス

  AppRunnerService:
    Type: AWS::AppRunner::Service
    Properties:
      ServiceName: !Sub ${SysName}-${Env}-apprunner-srv
      AutoScalingConfigurationArn: !GetAtt AppRunnerServiceASConfig.AutoScalingConfigurationArn
      HealthCheckConfiguration:
        Protocol: "HTTP"
        Path: "/"
      InstanceConfiguration:
        Cpu: !Ref ServiceCPU
        Memory: !Ref ServiceMemory
        InstanceRoleArn: !GetAtt InstanceRoleforAppRunner.Arn
      SourceConfiguration:
        AuthenticationConfiguration:
          AccessRoleArn: !GetAtt AccessRoleforAppRunner.Arn
        AutoDeploymentsEnabled: true
        ImageRepository:
          ImageIdentifier: !Ref ImageUri
          ImageRepositoryType: ECR
          ImageConfiguration:
            Port: 80

アプリケーションの稼働環境となるApp Runnerサービスを作成します。

AutoScailingConfigurationArnには、先ほど記述したオートスケーリング設定のARNを指定します。

HealthCheckConfigurationには、コンテナインスタンスのヘルスチェック設定を指定します。プロトコルのデフォルトがTCPとなっているので、HTTPでヘルスチェックを行う場合、ProtocolHTTPを指定し、Pathにはヘルスチェック用のパスを指定します。

InstanceConfigurationには、コンテナインスタンスで使用するCPU/Memory、及び先ほど記述したインスタンスロールのARNを指定します。コンテナインスタンスで選択可能なCPUとMemoryの組み合わせについては、以下の記事をご参照ください。

SourceConfigurationには、先ほど記述したアクセスロールのARN、自動デプロイ機能の有無、及びECRリポジトリ設定を指定します。ImageIdentifierには、ECRリポジトリに格納されているコンテナイメージのURIを指定します。

WebACL

  WebACL:
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub ${SysName}-${Env}-WebACL
      Scope: REGIONAL
      DefaultAction:
        Block: {}
      VisibilityConfig:
        SampledRequestsEnabled: true
        CloudWatchMetricsEnabled: true
        MetricName: !Sub ${SysName}-${Env}-WebACL
      Rules:
        - Name: 'allow-only-custom-domain'
          Priority: 0
          Action:
            Block: {}
          Statement:
            NotStatement:
              Statement:
                ByteMatchStatement:
                  FieldToMatch:
                    SingleHeader:
                      Name: 'host'
                  PositionalConstraint: 'EXACTLY'
                  SearchString: !Ref CustomDomain
                  TextTransformations:
                    - Priority: 0
                      Type: 'LOWERCASE'
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: 'allow-only-custom-domain'
        - Name: 'ip-addresses-white-list'
          Priority: 1
          Action:
            Allow: {}
          Statement:
            IPSetReferenceStatement:
              Arn: !GetAtt WAFIPSet.Arn
          VisibilityConfig:
            SampledRequestsEnabled: true
            MetricName: 'ip-addresses-white-list'
            CloudWatchMetricsEnabled: true

  WAFIPSet:
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: IPAllowLists
      Scope: REGIONAL
      IPAddressVersion: IPV4
      Addresses: !Ref AllowAddresses

  WebACLAssociation:
    Type: AWS::WAFv2::WebACLAssociation
    Properties:
      WebACLArn: !GetAtt WebACL.Arn
      ResourceArn: !GetAtt AppRunnerService.ServiceArn

WebACLを作成し、App Runnerサービスに紐づけます。

DefaultActionには、今回ホワイトリストによるIP制限ルールを採用するため、Blockを指定しアクセスを禁止します。

Rulesには、今回WebACLに適用するドメイン制限ルール及びIPアドレス制限ルールを指定します。

allow-only-custom-domainルールは、Statement.NotStatement.Statement.ByteMatchStatement.SearchStringに指定したカスタムドメイン以外からのアクセスを禁止します。

ip-addresses-white-listルールは、Statement.IPSetReferenceStatement.Arnに指定したWAFIPSetに含まれる、IPアドレスからのアクセスを許可します。

WebACLAssociationには、WebACLとApp RunnerサービスのARNを指定し紐づけます。

WAF用ログ設定

  LogGroupforWAF:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub aws-waf-logs-${SysName}-${Env}
      RetentionInDays: 365

  ResourcePolicyforWAFToCW:
    Type: AWS::Logs::ResourcePolicy
    Properties:
      PolicyName: !Sub WAFToCWLogsPolicy-${SysName}-${Env}
      PolicyDocument:
        Fn::Sub:
          - |
            {
              "Version": "2012-10-17",
              "Statement": [
                {
                  "Effect": "Allow",
                  "Principal": {
                    "Service": "delivery.logs.amazonaws.com"
                  },
                  "Action": [
                    "logs:CreateLogStream",
                    "logs:PutLogEvents"
                  ],
                  "Resource": "${CloudWatchLogsLogGroupArn}",
                  "Condition": {
                    "StringEquals": {
                      "aws:SourceAccount": ${AWS::AccountId}
                    },
                    "ArnLike": {
                      "aws:SourceArn": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"
                    }
                  }
                }
              ]
            }
          - CloudWatchLogsLogGroupArn: !GetAtt LogGroupforWAF.Arn

  WAFLogConfig:
    Type: AWS::WAFv2::LoggingConfiguration
    Properties:
      LogDestinationConfigs:
        - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:aws-waf-logs-${SysName}-${Env}
      ResourceArn: !GetAtt WebACL.Arn

CloudWatchロググループを作成し、WAFのログ設定の出力先として指定します。

ResourcePolicyforWAFToCWには、WAFからCloudWatch Logsへのログ出力を許可するためのリソースポリシーを設定します。

詳細については、以下の記事をご参照ください。

稼働確認

上記のテンプレートをデプロイする事で、特定のIPアドレス/カスタムドメインからApp Runnerにアクセスできるか確認します。

ECRへのコンテナイメージプッシュ

ECRプライベートリポジトリを、以下の通り事前に作成してください。

リポジトリが作成できましたら、AWS CLI及びDockerが導入されているローカル端末で以下のコマンドを実施し、作成したリポジトリに対してコンテナイメージをプッシュしてください。

# ECRリポジトリ名の設定
ECR_REPO_NAME=cm-dev-ecr-repo

# AWS IDの設定
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text)

# レジストリの認証
aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com

# Dockerfileの作成
cat << EOF > Dockerfile
FROM nginx:latest
EOF

# コンテナのビルド/プッシュ
docker build --platform linux/x86_64 -t ${ECR_REPO_NAME} .
docker tag ${ECR_REPO_NAME}:latest ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}:latest
docker push ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPO_NAME}:latest

作成したECRにプッシュしたコンテナイメージのURIをメモしておきます。

CloudFormationスタックのデプロイ

今回は以下のパラメータで、CloudFormationスタックをデプロイします。

ImageUriには、先ほどメモしたコンテナイメージのURIを指定します。

CustomDomainには、App Runnerのカスタムドメインリンクで指定するドメインを入力してください。今回はcm.tsukuboshi.netを指定します。

AllowAddressesには、App Runnerへのアクセスを許可するIPアドレスを入力します。今回は検証のため、checkip.amazonaws.comで確認したローカル端末のグローバルIPアドレスを指定します。

App Runnerカスタムドメインリンク構成

残念ながら2023年12月時点では、App RunnerのカスタムドメインリンクはCloudFormationには対応していないようです。

そのため以下のブログを参考に、コンソール上でカスタムドメインリンクを構成します。

今回は事前にtsukuboshi.netというホストゾーンを作成した上で、cm.tsukuboshi.netというカスタムドメインを指定します。

以下の通り、作成したカスタムドメインリンクのステータスがアクティブになっていればOKです。

アクセス確認

CloudFormationに出力されたデフォルトドメイン及びカスタムドメインにアクセスし、正常にアクセス制限が設定されているか確認します。

まずカスタムドメインcm.tsukuboshi.netにアクセスすると、以下のようにアクセスが許可される事が分かります。

続いて、カスタムドメイン以外のApp Runnerデフォルトドメインにアクセスすると、以下のようにドメイン制限ルールによりアクセスが禁止される事が分かります。

最後にプロキシ切り替え等を実施して、アクセス元のグローバルIPアドレスを変更した上でカスタムドメインにアクセスすると、以下のようにIPアドレス制限ルールによりアクセスが禁止される事が分かります。

このように、App Runnerに対して正常にアクセス制限が実施されている事が確認できました!

最後に

今回はドメイン及びIPアドレスによるアクセス制限ルールを含んだWAFをアタッチしたApp RunnerをCloudFormationで構築し、利用する方法を紹介しました。

App Runnerでアプリケーションを稼働させる際に良く見かける構成だと思いますので、ぜひお役に立てて頂けますと幸いです。

以上、つくぼし(tsukuboshi0755)でした!