前回の記事「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イメージのみ残す設定としています。
{
"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設定は以下の通りです。
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する」を参照してください。
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}\"
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, テスト!」と返ってくれば成功です
(補足)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
使ってみた感想
アプリのデプロイは楽
サービスの強制更新を行うだけでデプロイが可能な点は楽です。イメージpushも自動化できているため、通常のWebアプリでは十分使えるものだと感じました。
Blue/Greenやカナリアリリースなどを行う場合は手間がかかる
この場合はALBのTargetGroupをいじる必要があるため、少々面倒そうです。調べているとまさに!という記事を見つけましたので興味のある方は、以下の記事を見ていただければと思います。
AmazonECS / Fargate 本番運用のための構築とデプロイ方法まとめ
いろいろ考えると、ElasticBeanstalkという選択肢も捨てきれないというのがモヤモヤします
まとめ
今回はCloudformationを使うメリットが出たのでよかったです。
実際に商用で使ってみてはまることも多そうなので、これから何かで使ってみて知見をためようと思います!
#Jibを導入したことでFargateとは違うところに脱線しまくりでした。失敗。