LoginSignup
11
5

More than 3 years have passed since last update.

Cloudformationを使ったFargateサービスのデプロイプロセスについて考えてみた

Posted at

前回の記事「Cloudformationを使ったFargateバッチのデプロイプロセスについて考えてみた」に続いて、今回は Webアプリ についてのデプロイプロセスについて考えてみた結果をまとめます。

前提とするWebアプリのアーキテクチャ

ALBの配下にFargateのサービスが紐づく一般的な構成です。

Webアプリについて

OpenJDK11で、SpringBootを利用して作成した簡易なWebアプリです。

コードはこちら。
https://github.com/nyasba/fargate-spring-web

Jib というJavaプロジェクトをコンテナ化するgradle pluginを利用しており、個別にDockerfileは作成していません。Jibについてはいろんな記事が書かれていますので、興味のある方は参照してみてください。
ちなみに、Jibを使ったビルドでは、dockerのインストールは不要です。

公式
https://github.com/GoogleContainerTools/jib/tree/master/jib-gradle-plugin
ブログ
jib を使って Java アプリケーションを超簡単にコンテナ化!

アーキテクチャ設計方針

  • ECRのDockerイメージのlatestというタグに対して、タスク定義(webapp:1)を作成して、サービスを立ち上げる形にする
  • ECRへのイメージpushを行なってもサービスは更新されないため、サービスを強制更新する必要がある
  • 複数バージョンのアプリを扱いたい場合はタスク定義の複数バージョンが必要になる
    • 複数バージョンはカナリアリリースやBlueGreenなど共存させる方法を検討する必要がある
    • ALBのTargetGroupをいじる必要がありそうなので、今回はそこまでは考えない。

更新した Docker イメージがサービスに既存するタスク定義と同じタグを使用する場合 (my_image:latest など)、タスク定義の新しいリビジョンを作成する必要はありません。サービスの更新には、以下の手順を使用してサービスの現在の設定を維持し、[新しいデプロイの強制] を選択します。デプロイによって起動される新しいタスクは、リポジトリの開始時にリポジトリから現在のイメージあるいはタグを取得します。

初期構築

1. 権限周り(ロール作成)

Fargateタスク実行用のEcsTaskロール

Fargateの実行ロールです。名称は ecsTaskExecutionRole とします。
ロールの作成から、サービスElasticContainerService、ユースケースElasticContainerServiceTaskを選択して、ポリシーとしてAmazonECSTaskExecutionRolePolicyを付与します。

項目 設定値
信頼されたエンティティ ecs-tasks.amazonaws.com
ポリシー AmazonECSTaskExecutionRolePolicy

AutoScaling実行用ロール

AutoScalingの実行ロールです。名称は ecsAutoscaleRole とします。
ロールの作成から、サービスElastic Container Service、ユースケースElastic Container Service Autoscaleを選択して、ポリシーとしてAmazonEC2ContainerServiceAutoscaleRoleを付与します。

項目 設定値
信頼されたエンティティ application-autoscaling.amazonaws.com
ポリシー AmazonEC2ContainerServiceAutoscaleRole

2. ECRリポジトリの作成

WebアプリのDockerイメージを管理するためのECRリポジトリを作成します。Fargateのタスク定義を作成する前にリポジトリに何かのイメージをpushしておきたいのでこのタイミングで作ります。ECRで管理できるイメージ数には上限がありますので、LifecyclePolicyも設定しておきます。

AWS CLIの例です

aws ecr create-repository --repository-name webapp
aws ecr put-lifecycle-policy --repository-name webapp --lifecycle-policy-text file://deploy/ecr-policy.json

タグ付けされていない最新の1イメージのみ残す設定としています。

ecr-policy.json
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep only one untagged image, expire all others",
            "selection": {
                "tagStatus": "untagged",
                "countType": "imageCountMoreThan",
                "countNumber": 1
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}

3. WebアプリのDockerイメージをpush

手動で実施

ECRのDockerRegistryにログインして、ビルドしたDockerイメージをpushします。
Jibを使っているのでDockerコマンドは利用していませんが、Dockerfileを書きたい方はバッチの記事の方を参照ください。

JibでECRへログインする際に「amazon-ecr-credential-helper」を利用していますので、事前にインストールが必要です。

brew install docker-credential-helper-ecr

Jibの実行により、Dockerイメージが作成されて、ECRへのpushが実行されます。この際、ローカルにdockerコマンドは不要なようです。便利。

export ORG_GRADLE_PROJECT_ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...

./gradlew jib

Jibのgradle設定は以下の通りです。

build.gradle(抜粋)
jib {
    from {
        image = 'openjdk:11'
    }
    to {
        // 認証情報は AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY という環境変数で指定しておく
        // ORG_GRADLE_PROJECT_ACCOUNT_ID でAWSアカウントIDを認証情報に設定する
        image = "${ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/webapp"
        credHelper = 'ecr-login'
        tags = ['latest']
    }
}

CircleCIビルド

githubへのコードpushに伴うCircleCIでの自動化も検討してみました。が、

  • Jibを使うためにamazon-ecr-credential-helperのインストールが必要で、
  • amazon-ecr-credential-helperを使うためにGo言語のインストールが必要で・・

と前準備に手間がかかる結果になりました。
Dockerfileを記述してCircleCIのOrbでECRにpushした方がシンプルだったかもしれません。
興味がある方は「CircleCIを使ってECRへイメージをpushする」を参照してください。

circleci/config.yml
version: 2.1
jobs:
  build:
    working_directory: ~/workspace
    docker:
      - image: circleci/openjdk:11-jdk
    steps:
      - checkout
      - restore_cache:
          keys:
            - v1-dependencies-{{ checksum "build.gradle" }}
            # fallback to using the latest cache if no exact match is found
            - v1-dependencies-
      - run: gradle dependencies
      - save_cache:
          paths:
            - ~/.gradle
          key: v1-dependencies-{{ checksum "build.gradle" }} 
      - run:
          name: run test
          command: ./gradlew test
      - run:
          name: download go
          command: wget -O - "https://dl.google.com/go/go1.12.5.linux-amd64.tar.gz" | tar -zxvf - -C ./ 
      - run:
          name: get ecr-helper
          command: ./go/bin/go get -u github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cli/docker-credential-ecr-login
      - run:
          name: set go path
          command: echo 'export PATH=$HOME/go/bin:$PATH' >> $BASH_ENV 
      - run:
          name: push ecr
          command: ./gradlew jib
workflows:
  test_and_push_image:
    jobs:
      - build

4. Fargateのデプロイ

次にFargateでWebアプリを実行するために必要な、ALBやECSタスク定義、ECSクラスター、サービスなどをCloudformationで作ります。

export VPC=vpc-1111111
export SUBNET_A=subnet-aaaaaaa
export SUBNET_C=subnet-ccccccc

aws cloudformation create-stack --stack-name fargate-webapp \
  --template-body file://deploy/fargate-webapp.cf.yml \
  --parameters \
      ParameterKey=ProjectName,ParameterValue=fargate-webapp \
      ParameterKey=ECRRepositoryName,ParameterValue=webapp \
      ParameterKey=ECSTaskCPUUnit,ParameterValue=256 \
      ParameterKey=ECSTaskMemory,ParameterValue=512 \
      ParameterKey=ECSTaskDesiredCount,ParameterValue=1 \
      ParameterKey=TaskExecutionRoleName,ParameterValue=ecsTaskExecutionRole \
      ParameterKey=AutoscalingRoleName,ParameterValue=ecsAutoscaleRole \
      ParameterKey=VpcId,ParameterValue=${VPC} \
      ParameterKey=ALBSubnets,ParameterValue=\"${SUBNET_A},${SUBNET_C}\"
fargate-webapp.cf.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: 'fargate web service'
Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Project Name Prefix"
        Parameters:
          - ProjectName
      - Label:
          default: "Fargate Configuration"
        Parameters:
          - ECRRepositoryName
          - ECSTaskCPUUnit
          - ECSTaskMemory
          - ECSTaskDesiredCount
          - TaskExecutionRoleName
          - AutoscalingRoleName
      - Label:
          default: "Netowork Configuration"
        Parameters:
          - VpcId
          - ALBSubnets

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------# 
Parameters:
  ProjectName:
    Type: String
    Default: "fargate-webapp"

#Network Configurations
  VpcId:
    Description : "VPC ID"
    Type: AWS::EC2::VPC::Id
  ALBSubnets:
    Description : "ALB Subnet"
    Type : "List<AWS::EC2::Subnet::Id>"

#ECSTask Configurations
  ECRRepositoryName:
    Type: String
    Default: "webapp"
  ECSTaskCPUUnit:
    AllowedValues: [ 256, 512, 1024, 2048, 4096  ]
    Type: String
    Default: "256"
  ECSTaskMemory:
    AllowedValues: [ 256, 512, 1024, 2048, 4096  ]
    Type: String
    Default: "512"
  ECSTaskDesiredCount:
    Type: Number
    Description: the number of instances
    Default: 1
  TaskExecutionRoleName:
    Type: String
    Description: A name of task execution role
    Default: 'ecsTaskExecutionRole'
  AutoscalingRoleName:
    Type: String
    Description: A name of AutoScaling role
    Default: 'ecsAutoscaleRole'

Resources:
# ------------------------------------------------------------#
#  SecurityGroup for ALB
# ------------------------------------------------------------#
  ALBSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: !Ref VpcId
      GroupName: !Sub "${ProjectName}-alb-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${ProjectName}-alb-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: "0.0.0.0/0"
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: "0.0.0.0/0"

# ------------------------------------------------------------#
#  SecurityGroup for ECS Service
# ------------------------------------------------------------#
  ECSSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: !Ref VpcId
      GroupName: !Sub "${ProjectName}-ecs-sg"
      GroupDescription: "-"
      Tags:
        - Key: "Name"
          Value: !Sub "${ProjectName}-ecs-sg"

  ECSSecurityGroupIngress: 
    Type: "AWS::EC2::SecurityGroupIngress"
    Properties: 
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourceSecurityGroupId: !GetAtt [ALBSecurityGroup, GroupId]
      GroupId: !GetAtt [ECSSecurityGroup, GroupId]

# ------------------------------------------------------------#
#  Target Group
# ------------------------------------------------------------#
  TargetGroup:
    Type: "AWS::ElasticLoadBalancingV2::TargetGroup"
    Properties:
      VpcId: !Ref VpcId
      Name: !Sub "${ProjectName}-tg"
      Protocol: HTTP
      Port: 80
      TargetType: ip
      HealthCheckPath: "/healthcheck"

# ------------------------------------------------------------#
#  Internet ALB
# ------------------------------------------------------------#
  InternetALB:
    Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
    Properties:
      Name: !Sub "${ProjectName}-alb"
      Tags:
        - Key: Name
          Value: !Sub "${ProjectName}-alb"
      Scheme: "internet-facing"
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Subnets: !Ref ALBSubnets

  ALBListener:
    Type: "AWS::ElasticLoadBalancingV2::Listener"
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref TargetGroup
          Type: forward
      LoadBalancerArn: !Ref InternetALB
      Port: 80
      Protocol: HTTP

# ------------------------------------------------------------#
# ECS Cluster
# ------------------------------------------------------------#
  ECSCluster:
    Type: "AWS::ECS::Cluster"
    Properties:
      ClusterName: !Sub "${ProjectName}-cluster"

# ------------------------------------------------------------#
#  ECS LogGroup
# ------------------------------------------------------------#
  ECSLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/${ProjectName}-ecs-group"

# ------------------------------------------------------------#
#  ECS TaskDefinition
# ------------------------------------------------------------#
  ECSTaskDefinition:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
      Cpu: !Ref ECSTaskCPUUnit
      ExecutionRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/${TaskExecutionRoleName}"
      Family: !Sub "${ProjectName}-task"
      Memory: !Ref ECSTaskMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ContainerDefinitions:
        - Name: !Sub "${ProjectName}-container"
          Image: !Sub "${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ECRRepositoryName}:latest"
          Environment: 
            - Name: "Key"
              Value: "Test"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref ECSLogGroup
              awslogs-region: !Ref "AWS::Region"
              awslogs-stream-prefix: !Ref ProjectName
          MemoryReservation: 128
          PortMappings:
            - HostPort: 80
              Protocol: tcp
              ContainerPort: 80

# ------------------------------------------------------------#
#  ECS Service
# ------------------------------------------------------------#
  ECSService:
    Type: AWS::ECS::Service
    DependsOn: ALBListener
    Properties:
      Cluster: !Ref ECSCluster
      DesiredCount: !Ref ECSTaskDesiredCount
      LaunchType: FARGATE
      LoadBalancers:
        - TargetGroupArn: !Ref TargetGroup
          ContainerPort: 80
          ContainerName: !Sub "${ProjectName}-container"
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          SecurityGroups:
            - !Ref ECSSecurityGroup
          Subnets: !Ref ALBSubnets
      ServiceName: !Sub "${ProjectName}-service"
      TaskDefinition: !Ref ECSTaskDefinition
      HealthCheckGracePeriodSeconds: 120 # wait for spring boot app starting

# ------------------------------------------------------------#
#  Auto Scaling
# ------------------------------------------------------------#
  AutoScalingTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Properties:
      MaxCapacity: 3
      MinCapacity: 1
      ResourceId:
        Fn::Join:
        - "/"
        - - service
          - !Sub "${ProjectName}-cluster"
          - Fn::GetAtt:
            - ECSService
            - Name
      RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/${AutoscalingRoleName}"
      ScalableDimension: ecs:service:DesiredCount
      ServiceNamespace: ecs

  AutoScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: ECSScalingBlogPolicy
      PolicyType: TargetTrackingScaling 
      ScalingTargetId: !Ref AutoScalingTarget
      TargetTrackingScalingPolicyConfiguration:
        PredefinedMetricSpecification: 
          PredefinedMetricType: ECSServiceAverageCPUUtilization 
        DisableScaleIn: False
        ScaleInCooldown: 300
        ScaleOutCooldown: 300
        TargetValue: 80

Outputs:
  EndpointUrl:
    Description: "Endpoint url"
    Value: !GetAtt InternetALB.DNSName

Stackの出力タブを確認すると、ALBのエンドポイントが確認できます。このURLをブラウザで開いて、「Hello, テスト!」と返ってくれば成功です
スクリーンショット 2019-05-15 20.01.05.png

ECSでもサービスが立ち上がっているのが確認できます。
スクリーンショット 2019-05-15 20.02.42.png

(補足)FargateのAutoscalingPolicyについて

AutoScalingPolicyは以下の2種類存在します。
今回はシンプルなTargetTrackingScalingPolicyを使いましたが、採用するメトリクスの動きが激しい場合はスケールIN/OUTが頻繁に実施されてしまう可能性がありますので、StepScalingPolicyの方が使いやすいかもしれません。選択肢としては認識しておくとよいかと思います

ポリシー名 仕様 Cloudformationの設定リファレンス
TargetTrackingScalingPolicy 指定されたメトリクス(CPU使用率やメモリ使用量など)が指定された値に近づくように自動的にスケールIN/OUTを行うポリシー。 AWSドキュメント
StepScalingPolicy 条件に合致した際に段階的にスケールさせるポリシー。スケールIN/OUTそれぞれで定義する。 AWSドキュメント

アプリ修正に伴うデプロイ

1. Dockerイメージの再push

初期構築の3の手順と同じです。
手動、もしくはコードpushに伴うCircleCIのビルドにて、latestタグのイメージを置き換える形になります。

2. サービスの強制更新

Fargateのサービスを強制更新することで稼働しているアプリが置き換わります。

aws ecs update-service --cluster fargate-webapp-cluster --service fargate-webapp-service --force-new-deployment

使ってみた感想

:o: アプリのデプロイは楽

サービスの強制更新を行うだけでデプロイが可能な点は楽です。イメージpushも自動化できているため、通常のWebアプリでは十分使えるものだと感じました。

:question: Blue/Greenやカナリアリリースなどを行う場合は手間がかかる

この場合はALBのTargetGroupをいじる必要があるため、少々面倒そうです。調べているとまさに!という記事を見つけましたので興味のある方は、以下の記事を見ていただければと思います。

AmazonECS / Fargate 本番運用のための構築とデプロイ方法まとめ

いろいろ考えると、ElasticBeanstalkという選択肢も捨てきれないというのがモヤモヤします:thinking:

まとめ

今回はCloudformationを使うメリットが出たのでよかったです。
実際に商用で使ってみてはまることも多そうなので、これから何かで使ってみて知見をためようと思います!

#Jibを導入したことでFargateとは違うところに脱線しまくりでした。失敗。

11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5