ECS(Fargate)でDynamoDBを使用したWebアプリを作成する

ECS(Fargate)でDynamoDBを使用したWebアプリを作成する

ECS(Fargate)でDynamoDBを使用したWebアプリを作成する

以下のページでプライベートサブネット内のECS(Fargate)をALBにアタッチする方法を紹介しました。

https://awstut.com/2022/01/29/attach-fargate-in-private-subnet-to-elb

上記のページでは、NginxでWebサーバを立ち上げ、静的なHTMLファイルを返すコンテナをご紹介しました。

本ページでは、PythonでDynamoDBにアクセスする簡単なWebアプリをコンテナ化し、これをALBに関連付けます。
DynamoDBにアクセスし、全データを返すことを目指します。

構築する環境

Diagram of creating Web app using DynamoDB with ECS(Fargate) 1

基本的な構成は上述のページと同様です。

変更点は3点です。

1点目はFargate上で動作するコンテナです。
Python製Webフレームワークbottleを使って、コンテナをWebサーバとして動作させ、DynamoDBに保存されているデータを返します。

2点目はECRリポジトリへのイメージのプッシュ方法です。
今回は上述のコンテナ用のイメージを、CodeBuildを使ってビルドします。
CodeBuildの開始は、Lambda関数でトリガーします。
この関数をCloudFormationカスタムリソースに関連づけ、CloudFormationスタック作成時に自動的に実行されるように設定します。

3点目はDynamoDBです。
こちらもCloudFormationカスタムリソースに関連付けたLambda関数によって、スタック作成時に、自動的にDyanamoDBテーブルにアイテムを保存するように設定します。
なお保存するアイテム情報は、S3バケットにJSON形式で保存したものを参照します。

環境構築用のCloudFormationテンプレートファイル

上記の構成をCloudFormationで構築します。
以下のURLにCloudFormationテンプレートを配置してます。

https://github.com/awstut-an-r/awstut-dva/tree/main/02/004

テンプレートファイルのポイント解説

本ページは、ECS(Fargate)コンテナにおいて、DynamoDBにアクセスする方法を中心に取り上げます。

プライベートサブネット内のリソースを、ALBにアタッチする方法については、以下のページをご確認ください。

https://awstut.com/2021/11/28/attach-private-ec2-to-elb

Fargateの基本については、以下のページをご確認ください。

https://awstut.com/2022/01/25/introduction-to-fargate

DynamoDB

VPCエンドポイント

Resources:
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: !Ref CidrIp3
      VpcId: !Ref VPC
      AvailabilityZone: !Sub "${AWS::Region}${AvailabilityZone1}"

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: !Ref CidrIp4
      VpcId: !Ref VPC
      AvailabilityZone: !Sub "${AWS::Region}${AvailabilityZone2}"

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC

  PrivateSubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable

  PrivateSubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  DynamoDBEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      RouteTableIds:
        - !Ref PrivateRouteTable
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.dynamodb"
      VpcId: !Ref VPC
Code language: YAML (yaml)

DynamoDBにアクセスするECSコンテナはプライベートサブネットに設置されているため、同リソースに接続するための経路を用意する必要があります。

今回はDynamoDB用のVPCエンドポイントを作成します。

プライベートサブネットからDynamoDBテーブルに接続する方法については、以下のページをご確認ください。

https://awstut.com/2023/04/23/two-ways-to-access-dynamodb-from-private-subnet

DynamoDBテーブル

Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Properties:
      AttributeDefinitions:
        - AttributeName: Artist
          AttributeType: S
        - AttributeName: SongTitle
          AttributeType: S
      BillingMode: PAY_PER_REQUEST
      KeySchema:
        - AttributeName: Artist
          KeyType: HASH
        - AttributeName: SongTitle
          KeyType: RANGE
      TableClass: STANDARD
      TableName: !Sub "${Prefix}-Music"
Code language: YAML (yaml)

特別な設定は行いません。

以下のAWS公式ページで紹介されている構成を、CloudFormationテンプレートに書き出しました。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/getting-started-step-1.html

DynamoDBに関する基本的な事項に関しては、以下のページもご確認ください。

https://awstut.com/2022/02/07/introduction-to-dynamodb

ECS

Resources:
  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "${Prefix}-cluster"

  FargateTaskExecutionRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

  TaskRole:
    Type: AWS::IAM::Role
    DeletionPolicy: Delete
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: AllowSQSSendMessage
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - dynamodb:Scan
                Resource:
                  - !Sub "arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${Table}"

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      ContainerDefinitions:
        - Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRRepositoryName}:latest"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: !Ref Prefix
          Name: !Sub "${Prefix}-bottle"
          PortMappings:
            - ContainerPort: !Ref BottlePort
              HostPort: !Ref BottlePort
          Environment:
            - Name: TABLE_NAME
              Value: !Ref Table
      Cpu: !Ref TaskCpu
      ExecutionRoleArn: !Ref FargateTaskExecutionRole
      Memory: !Ref TaskMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      RuntimePlatform:
        CpuArchitecture: ARM64
        OperatingSystemFamily: LINUX
      TaskRoleArn: !Ref TaskRole

  Service:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !Ref Cluster
      DesiredCount: 1
      LaunchType: FARGATE
      LoadBalancers:
        - ContainerName: !Sub "${Prefix}-bottle"
          ContainerPort: !Ref BottlePort
          TargetGroupArn: !Ref ALBTargetGroup
      NetworkConfiguration:
        AwsvpcConfiguration:
          SecurityGroups:
            - !Ref ContainerSecurityGroup
          Subnets:
            - !Ref PrivateSubnet1
            - !Ref PrivateSubnet2
      ServiceName: !Sub "${Prefix}-service"
      TaskDefinition: !Ref TaskDefinition
Code language: YAML (yaml)

ポイントは2点です。

1点目はタスク用ロールです。
インラインポリシーでDynamoDBにスキャンする権限を与えます。

2点目はタスク定義です。
Environmentプロパティでコンテナ内でアクセスできる環境変数が設定できますが、DynamoDBテーブルの名前を設定します。

(参考)CloudFormationカスタムリソースとCodeBuildを使用して、CloudFormationスタック作成時に、ECRリポジトリに自動的にテストイメージをプッシュする

Resources:
  CodeBuildProject:
    Type: AWS::CodeBuild::Project
    Properties:
      Artifacts:
        Type: NO_ARTIFACTS
      Cache:
        Type: NO_CACHE
      Environment:
        ComputeType: !Ref ProjectEnvironmentComputeType
        EnvironmentVariables:
          - Name: BOTTLE_PORT
            Type: PLAINTEXT
            Value: !Ref BottlePort
          - Name: DOCKERHUB_PASSWORD
            Type: SECRETS_MANAGER
            Value: !Sub "${Secret}:password"
          - Name: DOCKERHUB_USERNAME
            Type: SECRETS_MANAGER
            Value: !Sub "${Secret}:username"
        Image: !Ref ProjectEnvironmentImage
        ImagePullCredentialsType: CODEBUILD
        Type: !Ref ProjectEnvironmentType
        PrivilegedMode: true
      LogsConfig:
        CloudWatchLogs:
          Status: DISABLED
        S3Logs:
          Status: DISABLED
      Name: !Ref Prefix
      ServiceRole: !GetAtt CodeBuildRole.Arn
      Source:
        Type: NO_SOURCE
        BuildSpec: !Sub |
          version: 0.2

          phases:
            pre_build:
              commands:
                - echo Logging in to Amazon ECR...
                - aws --version
                - aws ecr get-login-password --region ${AWS::Region} | docker login --username AWS --password-stdin ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com
                - REPOSITORY_URI=${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRRepositoryName}
                - COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
                - IMAGE_TAG=${!COMMIT_HASH:=latest}
                - echo Logging in to Docker Hub...
                - echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USERNAME --password-stdin
                - |
                  cat << EOF > Dockerfile
                  FROM amazonlinux
                  RUN yum update -y && yum install python3 python3-pip -y
                  RUN pip3 install boto3 bottle
                  COPY main.py ./
                  CMD ["python3", "main.py"]
                  EXPOSE $BOTTLE_PORT
                  EOF
                - |
                  cat << EOF > main.py
                  from bottle import Bottle, route, run
                  import boto3
                  import json
                  import os

                  TABLE_NAME = os.environ['TABLE_NAME']

                  app = Bottle()
                  dynamodb_client = boto3.client('dynamodb')

                  @app.route('/')
                  def scan():
                    response = dynamodb_client.scan(
                      TableName=TABLE_NAME
                    )
                    return json.dumps(response['Items'], indent=2)

                  if __name__ == '__main__':
                    run(app=app, host='0.0.0.0', port=$BOTTLE_PORT)
                  EOF
            build:
              commands:
                - echo Build started on `date`
                - echo Building the Docker image...
                - docker build -t $REPOSITORY_URI:latest .
                - docker tag $REPOSITORY_URI:latest $REPOSITORY_URI:$IMAGE_TAG
            post_build:
              commands:
                - echo Build completed on `date`
                - echo Pushing the Docker images...
                - docker push $REPOSITORY_URI:latest
                - docker push $REPOSITORY_URI:$IMAGE_TAG
      Visibility: PRIVATE
Code language: YAML (yaml)

ECS上で実行させるコンテナ用イメージは、手動でECRリポジトリにプッシュしたものを使用することができます。
ただし本ページでは、CodeBuildを使ってイメージを自動的にビルドし、ECRリポジトリにプッシュします。
ビルドの開始のトリガーは、CloudFormationカスタムリソースに紐づいたLambda関数を使用します。
詳細につきましては、以下のページをご確認ください。

https://awstut.com/2023/04/30/automatically-push-test-images-to-ecr-using-cfn-custom-resources-and-codebuild

ポイントはbuildspec.yml内の、pre_buildフェーズで実行する2つのコマンドです。

1点目はcatコマンドでDockerfileを作成する箇所です。
最新版のAmazon Linuxイメージをベースとして、Pythonやパッケージをインストール後、後述のスクリプトを実行する内容となります。
公開するポートの指定では、EnvironmentVariablesプロパティで指定した環境変数を使用します。

2点目は同じくcatコマンドでPythonスクリプトmain.pyを作成する箇所です。
Webフレームワークbottleを使用して、簡易的なWebサーバとして動作させる内容です。
ルートページにアクセスされた際に、DynamoDBテーブルをスキャンして、全アイテムを取得後、レスポンスとして返します。
アクセスするテーブルの名前は、環境変数を参照します。

(参考)CloudFormationカスタムリソースでDynamoDBを初期セットアップする

Resources:
  Function2:
    Type: AWS::Lambda::Function
    Properties:
      Environment:
        Variables:
          JSON_S3_BUCKET: !Ref JsonS3Bucket
          JSON_S3_KEY: !Ref JsonS3Key
          TABLE_NAME: !Ref Table
      Code:
        ZipFile: |
          import boto3
          import cfnresponse
          import json
          import os

          JSON_S3_BUCKET = os.environ['JSON_S3_BUCKET']
          JSON_S3_KEY = os.environ['JSON_S3_KEY']
          TABLE_NAME = os.environ['TABLE_NAME']

          CREATE = 'Create'
          response_data = {}

          s3_client = boto3.client('s3')
          dynamodb_client = boto3.client('dynamodb')

          def lambda_handler(event, context):
            try:
              if event['RequestType'] == CREATE:
                s3_response = s3_client.get_object(
                  Bucket=JSON_S3_BUCKET,
                  Key=JSON_S3_KEY)

                body = s3_response['Body'].read()
                print(body)

                json_data = json.loads(body.decode('utf-8'))
                print(json_data)

                for item in json_data:
                  dynamodb_response = dynamodb_client.put_item(
                    TableName=TABLE_NAME,
                    Item=item
                  )
                  print(dynamodb_response)

              cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)

            except Exception as e:
              print(e)
              cfnresponse.send(event, context, cfnresponse.FAILED, response_data)
      FunctionName: !Sub "${Prefix}-function2"
      Handler: !Ref Handler
      Runtime: !Ref Runtime
      Role: !GetAtt FunctionRole2.Arn
      Timeout: !Ref Timeout
Code language: YAML (yaml)

CloudFormationスタック作成時に、自動的にDynamoDBにテストデータを保存します。
上記のLambda関数をCloudFormationカスタムリソースに関連づけることで、これを実現します。

詳細につきましては、以下のページをご確認ください。

https://awstut.com/2022/12/28/initial-setup-of-dynamodb-with-cfn-custom-resources

ALBおよびECSのポート番号まとめ

今回の構成における通信を整理すると、以下の図となります。

Diagram of creating Web app using DynamoDB with ECS(Fargate) 2

ユーザからALBまでは80番ポート向けに通信が行われますが、ALBからECS(Fargate)までの通信はbottleアプリで使用するポート(8080)向けに通信が行われます。

上記の構成を実現するために、各リソースの各プロパティを以下の通りに設定します。

  • AWS::ElasticLoadBalancingV2::Listener Port:80
  • AWS::ElasticLoadBalancingV2::TargetGroup Port:8080
  • AWS::ECS::Service LoadBalancers ContainerPort:8080
  • AWS::ECS::TaskDefinition PortMappings ContainerPort:8080
  • AWS::ECS::TaskDefinition PortMappings HostPort:8080

両リソースにアタッチするセキュリティグループは以下の通りです。

Resources:
  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub ${Prefix}-ALBSecurityGroup
      GroupDescription: Allow HTTP Only.
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref HTTPPort
          ToPort: !Ref HTTPPort
          CidrIp: 0.0.0.0/0

  ContainerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub "${Prefix}-ContainerSecurityGroup"
      GroupDescription: Allow HTTP from ALBSecurityGroup.
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref BottlePort
          ToPort: !Ref BottlePort
          SourceSecurityGroupId: !Ref ALBSecurityGroup
Code language: YAML (yaml)

環境構築

CloudFormationを使用して、本環境を構築し、実際の挙動を確認します。

CloudFormationスタックを作成し、スタック内のリソースを確認する

CloudFormationスタックを作成します。
スタックの作成および各スタックの確認方法については、以下のページをご確認ください。

https://awstut.com/2021/12/02/cloudformation-nested-stacks

各スタックのリソースを確認した結果、今回作成された主要リソースの情報は以下の通りです。

  • ECRリポジトリ:dva-02-004
  • DynamoDBテーブル:dva-02-004-Music
  • ECSクラスター:dva-02-004-cluster
  • ALBのドメイン:dva-02-004-alb-909547272.ap-northeast-1.elb.amazonaws.com

AWS Management Consoleから各リソースを確認します。

ECRリポジトリを確認します。

Detail of ECR 1

正常にリポジトリが作成されて、イメージがプッシュされています。
つまりCloudFormationカスタムリソースに紐づくLambda関数が実行されて、CodeBuildによってイメージのビルドが開始され、リポジトリにプッシュされたということです。

DynamoDBテーブルを確認します。

Detail of DynamoDB 1

テーブルにデータが保存されています。
つまりCloudFormationカスタムリソースに紐づくLambda関数が実行されて、S3バケットに保存されているJSONデータが読み込まれて、DynamoDBテーブルにアイテムが保存されたということです。

ECSを確認します。

まずクラスターを確認します。

Detail of Fargate 1

クラスター内に、1つのECSサービスが作成されています。

サービス内のタスクを確認します。

Detail of Fargate 2

ALBに関連づいていることがわかります。

サービス内に1つのタスクが実行されていることも確認できます。

Detail of Fargate 3

タスク内に、先述のECRリポジトリからイメージをプルし、コンテナが生成されています。

動作確認

準備が整いましたので、ALBにアクセスします。

% curl http://dva-02-004-alb-909547272.ap-northeast-1.elb.amazonaws.com/
[ { "AlbumTitle": { "S": "Somewhat Famous" }, "Awards": { "N": "1" }, "Artist": { "S": "No One You Know" }, "SongTitle": { "S": "Call Me Today" } }, { "AlbumTitle": { "S": "Somewhat Famous" }, "Awards": { "N": "2" }, "Artist": { "S": "No One You Know" }, "SongTitle": { "S": "Howdy" } }, { "AlbumTitle": { "S": "Songs About Life" }, "Awards": { "N": "10" }, "Artist": { "S": "Acme Band" }, "SongTitle": { "S": "Happy Day" } }, { "AlbumTitle": { "S": "Another Album Title" }, "Awards": { "N": "8" }, "Artist": { "S": "Acme Band" }, "SongTitle": { "S": "PartiQL Rocks" } } ]Code language: Bash (bash)

ALBにアクセスすると、正常に応答がありました。
応答として、DynamoDBに保存されているアイテム情報が返ってきました。
ECS(Fargate)からDynamoDBにアクセスすることができました。

まとめ

DynamoDBにアクセスするECS(Fargate)コンテナアプリを作成しました。