Dockerをさわってみる④ (ECS+CloudFormation)

22 min read

はじめに

以前は、こちらの記事でdjangoのDockerコンテナをAWSのEC2上で動かしてみました。

今回は、AWSのコンテナオーケストレーションサービスであるECSを使って、上記で作成したDockerコンテナを動かしてみたいと思います。

前提

  • Windows 10 Pro
  • Docker for Windows 20.10.6 (ローカルでDockerコンテナをビルドするために必要)
  • aws-cli 2.1.38

構成

  • VPC内に、パブリックサブネットとプライベートサブネットを作成します。
  • パブリックサブネット内に、ALB (Application Load Balancer)とNAT Gatewayを配置します。
  • プライベートサブネット内に、ECSのサーバーレスの実行環境であるFargateを配置します。プライベートサブネットからのインターネットへのルーティングには、NAT Gatewayを指定します。

Fargeteのサービスを配置する場所はインターネットに通信できるサブネットに配置する必要があります。

Fargate タスクでは、パブリックリポジトリからイメージをプルしたり、シークレットを調達したりするなど、特定の操作にはインターネットアクセスが必要になる場合があります。

https://aws.amazon.com/jp/premiumsupport/knowledge-center/ecs-fargate-tasks-private-subnet/

インターネットにアクセスできない環境に配置すると、ECS ServiceがPendingのまま立ち上がらないので注意

  • 作成したコンテナイメージはAWSのコンテナレジストリサービスであるECRを利用します。
  • 上記環境をCloudFormationを使って作成していきます。
    ※最近はAWSのすべてのサービスをCloudFormationで作成するように意識しています。

ECSとは

ECS概要

Amazon Elastic Container Service (Amazon ECS) は、フルマネージド型のコンテナオーケストレーションサービスです。

https://aws.amazon.com/jp/ecs/?whats-new-cards.sort-by=item.additionalFields.postDateTime&whats-new-cards.sort-order=desc&ecs-blogs.sort-by=item.additionalFields.createdDate&ecs-blogs.sort-order=desc

コンテナオーケストレーションとは

  • 管理対象のコンテナが増えるとそれぞれのコンテナに対して運用やデプロイなどの管理を行うことが難しくなります。
  • そこで登場するのがコンテナオーケストレーションです。
  • 代表的なコンテナオーケストレーションサービスとしてGoogle社が社内で利用していたBorgというコンテナオーケストレーションツールをOSSとして利用できるようにしたKubernetesが存在します。

つまりAWSが独自に開発したコンテナオーケストレーションサービスがECSとなります。
ECSの中で、実行環境が2タイプあり、1つがEC2で、もう1つがサーバレスでインスタンスの管理を不要にしたFargateです。Fargateはインスタンスの管理が不要な分、EC2にSSHにログインしてログを取得するみたいなことができないので、ログを外部に出力するなどの対応が必要になります。

また、Amazon Elastic Container Registry (Amazon ECR)は、完全フルマネージドのコンテナレジストリサービスです。
DockerHubのように自分で作成したコンテナイメージを管理することができます。

ECSを構成する3要素

ECSを利用する際に理解しておいたほうが良い概念として、クラスター、タスク、サービスが存在します。

上記の17ページがわかりやすいです。(ページ指定で表示できなくてすみません。)

名称 説明
クラスター タスクとクラスターを論理的にグルーピングしたもの
タスク定義 コンテナイメージの場所、CPU&メモリ、ネットワークモードなどを定義する
タスク タスク定義によって起動されるコンテナ群
サービス タスクの実行数を維持する。ELBにはこれをぶら下げる。

ハンズオン

ECRの作成 & DockerイメージのPush

まず、自分で作成するDockerイメージを管理しておくためのレポジトリをECR上に作成します。

ecr.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Create ECR

Resources:
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: django_app

上記テンプレートファイルを用いて、CloudFormationスタックを作成します。
作成の仕方は割愛しますが、無事にレポジトリが作成されると以下の画面のようになります。

  • サービス > Elastic Container Service > リポジトリ

  • 上記画面の、「プッシュコマンドの表示」を押下します。

  • ECRにコンテナイメージをPushするためのコマンドが表示されます。(macOS/LinuxとWindowsの2つのタブがありますが、macOS/Linuxのほうでいけました。)

  • このコマンド通りにコマンドを叩いていきます。

  1. 認証トークンを取得し、作成したレジストリに対して Docker クライアントを認証します。
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin XXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com
  1. Dockerイメージを作成します。(Dockerfileが存在するパスへ移動してから)
docker build -t django_app .

なお、Dockerfileには以下を記述しました。

Dockerfile
# 1. ベースとなるイメージを指定
FROM python:3.9

# 2. 作業ディレクトリの作成
RUN mkdir /src

# 3. 作業ディレクトリの設定
WORKDIR /src

# 4. カレントディレクトリにあるファイルをコンテナ上の指定のディレクトリにコピーする
ADD . /src

# 5. pipでrequirements.txtに指定されているパッケージを追加する
RUN pip3 install -r requirements.txt

# 6. dbのmigration
RUN python3 ./MyDjangoProject/manage.py migrate

# 7. 起動(コンテナのポート80番で受け付けるように起動する)
CMD python3 ./MyDjangoProject/manage.py runserver 0.0.0.0:80

アプリケーションの作成自体は、こちらをご参照ください。
また、上記の記事と異なる点として、コンテナ側の80番ポートを開放しています。

  1. リポジトリにPushできるようにタグを付与します。
docker tag django_app:latest XXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/django_app:latest
  1. 以下のコマンドでレポジトリにDockerイメージをPushします。
docker push XXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/django_app:latest

ECRの画面を確認し、「django_app」を押下すると以下の画面に遷移し、イメージがPushできたことが確認できます。

ネットワーク環境の構築

ここから実際にCloudFormationを用いて環境の構築を行います。

  • 以下のテンプレートで、VPC, Subnet, Internet Gateway, NAT Gatewayを作成します。
  • CloudFormationの構文と、ネットワーク環境の構築は本題とはそれますので説明は割愛させていただきます。
  • CloudFormationについてはこちらでまとめておりますのでご参照ください。
  • NAT Gatewayの作成についてはこちらを参考にさせていただきました。
network.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Create Network (VPC, Subnet, Internet Gateway, NAT Gateway)

Resources: 

  # ------------------------------------------------------------#
  #  VPC
  # ------------------------------------------------------------#
  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      InstanceTenancy: default
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
      - Key: Name
        Value: MyVPC
  
  # ------------------------------------------------------------#
  #  Internet Gateway
  # ------------------------------------------------------------#
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
      - Key: Name
        Value: My-InternetGateway
  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref MyVPC
      InternetGatewayId: !Ref InternetGateway

  # ------------------------------------------------------------#
  #  Public Subnet Route Table
  # ------------------------------------------------------------#
  PublicSubnetRouteTable:
    Type: AWS::EC2::RouteTable
    DependsOn: AttachGateway
    Properties:
      VpcId: !Ref MyVPC
      Tags:
      - Key: Name
        Value: My-RouteTable
  Route:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicSubnetRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
  
  # ------------------------------------------------------------#
  #  Public Sunbet A
  # ------------------------------------------------------------#
  PublicSubnetA:
    Type: AWS::EC2::Subnet
    DependsOn: AttachGateway
    Properties:
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: 10.0.1.0/24
      MapPublicIpOnLaunch: 'true'
      VpcId: !Ref MyVPC
      Tags:
      - Key: Name
        Value: PublicSubnetA
  PublicSubetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicSubnetRouteTable

  # ------------------------------------------------------------#
  #  Public Sunbet C
  # ------------------------------------------------------------#
  PublicSubnetC:
    Type: AWS::EC2::Subnet
    DependsOn: AttachGateway
    Properties:
      AvailabilityZone: "ap-northeast-1c"
      CidrBlock: 10.0.2.0/24
      MapPublicIpOnLaunch: 'true'
      VpcId: !Ref MyVPC
      Tags:
      - Key: Name
        Value: PublicSubnetC
  PublicSubnetCRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetC
      RouteTableId: !Ref PublicSubnetRouteTable

  # ------------------------------------------------------------#
  #  NAT Gateway A
  # ------------------------------------------------------------#
  NatGatewayA:
    Type: AWS::EC2::NatGateway
    Properties:
      SubnetId: !Ref PublicSubnetA
      AllocationId: !GetAtt EIPForNATGatewayA.AllocationId
      Tags:
        - Key: Name
          Value: NatGatewayA

  EIPForNATGatewayA:
    Type: "AWS::EC2::EIP"
    Properties: 
      Domain: vpc

  # ------------------------------------------------------------#
  #  NAT Gateway C
  # ------------------------------------------------------------#
  NatGatewayC:
    Type: AWS::EC2::NatGateway
    Properties:
      SubnetId: !Ref PublicSubnetC
      AllocationId: !GetAtt EIPForNatGatewayC.AllocationId
      Tags:
        - Key: Name
          Value: NatGatewayC

  EIPForNatGatewayC:
    Type: "AWS::EC2::EIP"
    Properties: 
      Domain: vpc

  # ------------------------------------------------------------#
  #  Private Subnet A Route Table 
  # ------------------------------------------------------------#
  PrivateSubnetARouteTable:
    Type: AWS::EC2::RouteTable
    DependsOn: NatGatewayA
    Properties:
      VpcId: !Ref MyVPC
      Tags:
      - Key: Name
        Value: PrivateSubnetARouteTable
  PrivateSubnetARoute:
    Type: AWS::EC2::Route
    DependsOn: NatGatewayA
    Properties:
      RouteTableId: !Ref PrivateSubnetARouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGatewayA

  # ------------------------------------------------------------#
  #  Private Subnet Route Table 
  # ------------------------------------------------------------#
  PrivateSubnetCRouteTable:
    Type: AWS::EC2::RouteTable
    DependsOn: NatGatewayC
    Properties:
      VpcId: !Ref MyVPC
      Tags:
      - Key: Name
        Value: PrivateSubnetCRouteTable
  PrivateSubnetCRoute:
    Type: AWS::EC2::Route
    DependsOn: NatGatewayC
    Properties:
      RouteTableId: !Ref PrivateSubnetCRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGatewayC

  # ------------------------------------------------------------#
  #  Private Sunbet A
  # ------------------------------------------------------------#
  PrivateSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: "ap-northeast-1a"
      VpcId: !Ref MyVPC
      CidrBlock: 10.0.3.0/24
      Tags:
        - Key: Name
          Value: PrivateSubnetA
  PrivateSubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnetA
      RouteTableId: !Ref PrivateSubnetARouteTable

  # ------------------------------------------------------------#
  #  Private Sunbet C
  # ------------------------------------------------------------#
  PrivateSubnetC:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: "ap-northeast-1c"
      VpcId: !Ref MyVPC
      CidrBlock: 10.0.4.0/24
      Tags:
        - Key: Name
          Value: PrivateSubnetC
  PrivateSubnetCRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnetC
      RouteTableId: !Ref PrivateSubnetCRouteTable
  

Outputs:
  VPC:
    Value: !Ref MyVPC
    Export: 
      Name: VPCName
  PublicSubnetA:
    Value: !Ref PublicSubnetA
    Export:
      Name: PublicSubnetA
  PublicSubnetC:
    Value: !Ref PublicSubnetC
    Export:
      Name: PublicSubnetC
  PrivateSubnetA:
    Value: !Ref PrivateSubnetA
    Export:
      Name: PrivateSubnetA
  PrivateSubnetC:
    Value: !Ref PrivateSubnetC
    Export:
      Name: PrivateSubnetC
  • ここまでで以下の図のような環境が作成できました。

セキュリティグループ/ロールの作成

  • 以下で、ECSのサービスに割り当てるセキュリティグループ、ALBに割り当てるセキュリティグループ、ECSのタスクに割り当てるロールを作成します。
  • ALBにはインターネットからのHTTPのアクセスを受け付けるインバウンドルールを持ったセキュリティグループを割り当てます。
  • ECSにはサービスにはALBからのHTTPアクセスを受け付けるインバウンドルールを持ったセキュリティグループを割り当てます。
  • ECSのタスクには、ECRからコンテナイメージをPullする、CloudWatchLogsにログを送信するなどの権限を持たせる必要があるため、AmazonECSTaskExecutionRolePolicyなるポリシーを付与したロールを作成しています。
security.yml
AWSTemplateFormatVersion: 2010-09-09
Description: Create Security Group and Role
Resources: 

  # ------------------------------------------------------------#
  #  ALB Security Group
  # ------------------------------------------------------------#
  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !ImportValue VPCName
      GroupDescription: Allow HTTP from Internet
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: ALBSG

  # ------------------------------------------------------------#
  #  ECS Security Group
  # ------------------------------------------------------------#
  ECSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !ImportValue VPCName
      GroupDescription: Allow HTTP from ALB
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          SourceSecurityGroupId: !Ref ALBSecurityGroup
      Tags:
        - Key: Name
          Value: ECSSG

  # ------------------------------------------------------------#
  #  ECS Task Execution Role
  # ------------------------------------------------------------#
  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: "ECSTaskExecutionRole"
      Path: /
      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

Outputs:
  ECSSecurityGroup:
    Value: !Ref ECSSecurityGroup
    Export: 
      Name: ECSSecurityGroupName
  ALBSecurityGroup:
    Value: !Ref ALBSecurityGroup
    Export: 
      Name: ALBSecurityGroupName
  ECSTaskExecutionRole:
    Value: !Ref ECSTaskExecutionRole
    Export:
      Name: ECSTaskExecutionRoleName

Fargate環境の作成

  • 以下でALB, ECSのクラスター、サービス、タスク、CloudWatchロググループを作成しています。
  • クラスター名、タスク名、サービス名、コンテナ名、コンテナイメージはパラメータとして外だししています。
  • クラスターに関して、名前以外にプロパティは指定しませんでした。
  • タスクに関しては以下を設定しました。
設定項目 設定値 説明
Cpu 256 今回は最小値の256 (.25 vCPU)を使用。この時、Memoryには512 (0.5 GB), 1024 (1 GB), 2048 (2 GB)の3つの値を設定居できる。
Memory 512 最小の512 (.5 vCPU) を使用。この時Cpuには256 (.25 vCPU)の値のみ設定できる。
ExecutionRoleArn security.ymlで作成したECS用のロール タスク実行時に割り当てるロール
Family {StackName}-django-app タスクに割り当てる名前
NetworkMode awsvpc none, bridge, awsvpc,hostの4つの値をとることができる。awsvpcを設定すると、タスクにENIとIPv4 アドレスが割り当てられる。
RequiresCompatibilities FARGATE タスクが使用している起動タイプ。EC2ECSの2つのどちらかを設定できる。
ContainerDefinitions テンプレート参照 実行するコンテナに関する設定。詳細は割愛するが、CPU, メモリの設定や、イメージの指定、ロググループの指定、ポートマッピングなどを指定している。

詳細は以下を参照ください。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecs-taskdefinition.html#cfn-ecs-taskdefinition-memory
  • サービスに関しては以下を設定しました。
設定項目 設定値 説明
Cluster 作成したクラスターを指定 ECSクラスター名や、ARNを指定する。
DesiredCount 1 クラスター内で実行されるタスクの数。
LaunchType FARGATE サービスの実行タイプ。EC2,EXTERNAL,FARGATEが指定できる。
LoadBalancers テンプレート参照 LoadBalancerをリストで指定する。LoadBalancerには、ターゲットグループやコンテナ名、ポートなどを指定する。
NetworkConfiguration テンプレート参照 タスクのNetworkModeがawsvpcのときのみ必須。AwsvpcConfigurationを設定する。ここでは、サービスの起動するサブネットやセキュリティグループを指定する。
ServiceName Paramtersで指定した、サービス名 ECSのサービス名を指定する。
TaskDefinition 上記で作成したタスク タスクのFamilyかARNを指定する。
  • ALBは、上記で作成したパブリックサブネットに配置しています。
  • ターゲットグループでは、ターゲットタイプにIPを指定することに注意してください。
  • CloudWatchのロググループは、Type: AWS::Logs::LogGroupで作成しています。
ecs.yaml
AWSTemplateFormatVersion: 2010-09-09
Parameters:
  ECSClusterName:
    Description: ECS Cluster Name
    Type: String
    Default: django-cluster
  ECSTaskName:
    Description: ECS Task Name
    Type : String
    Default: django-task
  ECSServiceName:
    Description: ECS Service Name
    Type : String
    Default: django-service
  ECSContainerName:
    Description: Container Name
    Type: String
    Default: django-app
  ECSContainerImage:
    Description: Container Image URL
    Type: String
    Default: XXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/django_app

Resources:

  # ------------------------------------------------------------#
  # ECS Cluster
  # ------------------------------------------------------------#
  ECSCluster:
    Type: "AWS::ECS::Cluster"
    Properties:
      ClusterName: !Ref ECSClusterName

  # ------------------------------------------------------------#
  #  ECS TaskDefinition
  # ------------------------------------------------------------#
  ECSTaskDefinition:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
      Cpu: 256
      ExecutionRoleArn: !ImportValue ECSTaskExecutionRoleName
      Family: !Join ['', [!Ref 'AWS::StackName', -django-app]]
      Memory: 512
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ContainerDefinitions:
        - Name: !Ref ECSContainerName
          Cpu: 128
          Essential: 'true'
          Image: !Ref ECSContainerImage
          Memory: 128
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref CloudwatchLogsGroup
              awslogs-region: !Ref 'AWS::Region'
              awslogs-stream-prefix: django-app
          PortMappings:
            - HostPort: 80
              Protocol: tcp
              ContainerPort: 80

  # ------------------------------------------------------------#
  #  ECS Service
  # ------------------------------------------------------------#
  ECSService:
    Type: AWS::ECS::Service
    DependsOn: ALBListener
    Properties:
      Cluster: !Ref ECSCluster
      DesiredCount: 1
      LaunchType: FARGATE
      LoadBalancers:
        -
          TargetGroupArn: !Ref TargetGroup
          ContainerPort: 80
          ContainerName: !Ref ECSContainerName
      NetworkConfiguration:
        AwsvpcConfiguration:
           AssignPublicIp: ENABLED
           SecurityGroups:
             - !ImportValue ECSSecurityGroupName
           Subnets:
             - !ImportValue PrivateSubnetA
             - !ImportValue PrivateSubnetC
      ServiceName: !Ref ECSServiceName
      TaskDefinition: !Ref ECSTaskDefinition
  
  # ------------------------------------------------------------#
  #  ALB
  # ------------------------------------------------------------#
  ApplicationLoadBalancer:
    Type : AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: ApplicationLoadBalancer
      Scheme: "internet-facing"
      SecurityGroups:
        - !ImportValue ALBSecurityGroupName
      Subnets:
        - !ImportValue PublicSubnetA
        - !ImportValue PublicSubnetC
      Tags: 
        - Key: Name
          Value: ApplicationLoadBalancer

  # ------------------------------------------------------------#
  #  Target Group 
  # ------------------------------------------------------------#
  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: TargetGroup
      Port: 80
      Protocol: HTTP
      TargetType: ip
      Tags:
        - Key: Name
          Value: TargetGroup
      VpcId: !ImportValue VPCName

  # ------------------------------------------------------------#
  #  ALB Listner
  # ------------------------------------------------------------#
  ALBListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 80
      Protocol: HTTP

  # ------------------------------------------------------------#
  #  CloudWatch Log Group
  # ------------------------------------------------------------#
  CloudwatchLogsGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Join ['-', [ECSLogGroup, !Ref 'AWS::StackName']]
      RetentionInDays: 14
 

動作確認

  • 上記のテンプレートを使ってスタックを作成する。

  • スタックが4つ作成された。

  • ここで、サービス > EC2 > ロードバランサーから、上記テンプレートから作成されたALBを確認する。

  • このDNS名をコピーする。

  • これをブラウザに張り付けてパスを指定すると、意図通りの画面が表示された!

  • ちなみに、サービス > CloudWatch > ロググループを参照すると、/ecs/django-taskなるロググループが作成されており、ログが記録されていた。

最後に

今回は、ECSを使って、自作のDockerコンテナを起動しました。今回は、コンテナを1つしか起動しませんでしたが、ECSのようなコンテナオーケストレーションサービスはコンテナが複数あってこそだと思いますので、そのレベルまで活用できるように頑張っていきたいと思います。