AWS
CloudFormation
デプロイ
Fargate

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

前回の記事「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をいじる必要がありそうなので、今回はそこまでは考えない。



https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/update-service.html


更新した 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とは違うところに脱線しまくりでした。失敗。