こんにちは!株式会社ナイトレイで働くインフラエンジニアです。
今回は、CloudFormationを用いた実装の一部を紹介します!
背景
aws opsworksサービス終了に伴い、これまでchefで管理していたwebサイトのインフラリソースを、Cloud Formationで作り直すことに!ついでに、ec2インスタンスからECSに乗り換えることにしたので、一部共有します。
Cloud Formationとは
AWSのリソースをコードで管理できるサービスです。テンプレートと呼ばれるテキストファイル(YAML/JSON)を読み込むと、自動でAWSの環境を作ってくれます。料金は、利用しているリソース分支払えば良いだけで、Cloud Formation自体の利用は無料です。チュートリアルも用意されているのでご参考までに📕
EC2からECSへの移行について
ECS(Elastic Container Service)とは、Dockerコンテナのデプロイや運用管理を簡単に行うための、AWSのサービスです。コンテナベースでサービスを管理できるため、EC2と比べ、運用を簡素化、また効率化することができます。
プロジェクト構成
.
├── Makefile
├── README.md
└── {service_name}/
└── cf/
├── {env}/ 環境固有のファイル群
│ ├── .params
│ └── main.yml
└── templates/
├── app/
│ ├── task.yml
│ ├── ecr.yml
│ └── ecs.yml
└── lb/
└── alb.yml
実際に作成したものから、一部抜粋します。
サービス単位でディレクトリを切り、テンプレートとスタック作成のファイルを分離しました。
また、パラメータをgithubなどで管理したくないので、.paramsをs3に置き、実行時にshellでダウンロードしてくる仕様にしました。
(他にもネットワークやセキュリティなどなど作りましたが、本記事では割愛します。)
テンプレート作成
公式リファレンスを見ながら作っていきます。
ecr.yml
Parameters:
Env:
Type: String
ServiceName:
Type: String
Resources:
# ECR
EcrNginx:
Type: AWS::ECR::Repository
Properties:
RepositoryName: !Sub ${ServiceName}-${Env}-nginx
ImageTagMutability: MUTABLE
ImageScanningConfiguration:
ScanOnPush: true
EncryptionConfiguration:
EncryptionType: AES256
LifecyclePolicy:
LifecyclePolicyText: >
{
"rules": [
{
"action": {
"type": "expire"
},
"selection": {
"countType": "imageCountMoreThan",
"countNumber": 4,
"tagStatus": "any"
},
"description": "Delete more than 4 images",
"rulePriority": 1
}
]
}
RegistryId: !Ref AWS::AccountId
EcrPhp:
Type: AWS::ECR::Repository
Properties:
RepositoryName: !Sub ${ServiceName}-${Env}-php
ImageTagMutability: MUTABLE
ImageScanningConfiguration:
ScanOnPush: true
EncryptionConfiguration:
EncryptionType: AES256
LifecyclePolicy:
LifecyclePolicyText: >
{
"rules": [
{
"action": {
"type": "expire"
},
"selection": {
"countType": "imageCountMoreThan",
"countNumber": 4,
"tagStatus": "any"
},
"description": "Delete more than 4 images",
"rulePriority": 1
}
]
}
RegistryId: !Ref AWS::AccountId
微々たるものですが、コストかかるのでイメージは3世代管理に。
task.yml
Parameters:
Env:
Type: String
ServiceName:
Type: String
AccountId:
Type: String
WebCpu:
Type: Number
WebMemory:
Type: Number
NginxLatestTag:
Type: String
PhpLatestTag:
Type: String
Resources:
# Task Definition
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: !Sub ${ServiceName}-${Env}-web
Cpu: !Ref WebCpu
Memory: !Ref WebMemory
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
ExecutionRoleArn: !Sub arn:aws:iam::${AccountId}:role/ecsTaskExecutionRole
TaskRoleArn: !Sub arn:aws:iam::${AccountId}:role/ecsTaskRole
ContainerDefinitions:
- Name: !Sub ${ServiceName}-${Env}-nginx-container
Image: !Sub ${AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ServiceName}-${Env}-nginx:${NginxLatestTag}
Essential: true
PortMappings:
- HostPort: 80
ContainerPort: 80
Protocol: tcp
LinuxParameters:
initProcessEnabled: true
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-create-group: true
awslogs-group: !Sub /ecs/${ServiceName}-${Env}-nginx
awslogs-region: ap-northeast-1
awslogs-stream-prefix: "ecs"
awslogs-datetime-format: "%Y-%m-%d %H:%M:%S"
- Name: !Sub ${ServiceName}-${Env}-php-container
Image: !Sub ${AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${ServiceName}-${Env}-php:${PhpLatestTag}
EnvironmentFiles:
- Value: !Sub arn:aws:s3:::${ServiceName}-env/ecs/${Env}.env
Type: s3
PortMappings:
- HostPort: 9000
ContainerPort: 9000
Protocol: tcp
LinuxParameters:
initProcessEnabled: true
LogConfiguration:
LogDriver: awslogs
Options:
awslogs-create-group: true
awslogs-group: !Sub /ecs/${ServiceName}-${Env}-php
awslogs-region: ap-northeast-1
awslogs-stream-prefix: "ecs"
awslogs-datetime-format: "%Y-%m-%d %H:%M:%S"
Outputs:
TaskDefinition:
Value: !Ref TaskDefinition
スタンダードなnginxとphpコンテナ構成。
ecs.yml
Parameters:
Env:
Type: String
ServiceName:
Type: String
SgWebId:
Type: AWS::EC2::SecurityGroup::Id
TaskDefinition:
Type: String
PrivateSubnetCId:
Type: AWS::EC2::Subnet::Id
PrivateSubnetDId:
Type: AWS::EC2::Subnet::Id
TargetGroup:
Type: String
Resources:
# ECS Cluster
EcsCluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub ${ServiceName}-${Env}-cluster
# ECS Service
EcsService:
Type: AWS::ECS::Service
Properties:
PlatformVersion: 1.4.0
ServiceName: !Sub ${ServiceName}-${Env}-service
Cluster: !Ref EcsCluster
TaskDefinition: !Ref TaskDefinition
DesiredCount: 2
LaunchType: FARGATE
DeploymentConfiguration:
MaximumPercent: 200
MinimumHealthyPercent: 100
DeploymentCircuitBreaker:
Enable: true
Rollback: true
EnableExecuteCommand: true
HealthCheckGracePeriodSeconds: 120
PropagateTags: TASK_DEFINITION
NetworkConfiguration:
AwsvpcConfiguration:
Subnets:
- !Ref PrivateSubnetCId
- !Ref PrivateSubnetDId
SecurityGroups:
- !Ref SgWebId
AssignPublicIp: DISABLED
LoadBalancers:
- TargetGroupArn: !Ref TargetGroup
ContainerName: !Sub ${ServiceName}-${Env}-nginx-container
ContainerPort: 80
Outputs:
EcsCluster:
Value: !Ref EcsCluster
EcsService:
Value: !GetAtt EcsService.Name
パラメータのtypeはstringとリソースタイプでばらつきがありますが、まぁ良しとします(本当は、リソースタイプを指定すべきですね…)。
alb.yml
Parameters:
Env:
Type: String
AccountId:
Type: String
ServiceName:
Type: String
Domain:
Type: String
Certificates:
Type: String
SgAlbId:
Type: AWS::EC2::SecurityGroup::Id
VpcId:
Type: String
PublicSubnetCId:
Type: AWS::EC2::Subnet::Id
PublicSubnetDId:
Type: AWS::EC2::Subnet::Id
Resources:
Alb:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Name: !Sub ${ServiceName}-${Env}-alb
Scheme: internet-facing
Type: application
IpAddressType: ipv4
Subnets:
- !Ref PublicSubnetCId
- !Ref PublicSubnetDId
SecurityGroups:
- !Ref SgAlbId
LoadBalancerAttributes:
- Key: idle_timeout.timeout_seconds
Value: 120
TargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
Properties:
Name: !Sub ${ServiceName}-${Env}-tg
Port: 80
Protocol: HTTP
TargetType: ip
VpcId: !Ref VpcId
HealthCheckEnabled: true
HealthyThresholdCount: 2
HealthCheckTimeoutSeconds: 5
HealthCheckProtocol: HTTP
HealthCheckPort: 80
HealthCheckPath: /healthcheck
HealthCheckIntervalSeconds: 30
TargetGroupAttributes:
- Key: deregistration_delay.timeout_seconds
Value: 300
- Key: stickiness.enabled
Value: true
- Key: stickiness.type
Value: lb_cookie
- Key: stickiness.lb_cookie.duration_seconds
Value: 86400
DefaultListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
LoadBalancerArn: !Ref Alb
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: !Sub arn:aws:acm:ap-northeast-1:${AWS::AccountId}:certificate/${Certificates}
SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
DefaultActions:
- Type: fixed-response
FixedResponseConfig:
ContentType: text/plain
StatusCode: 403
CustomListener:
Type: AWS::ElasticLoadBalancingV2::ListenerRule
Properties:
Priority: 100
ListenerArn: !Ref DefaultListener
Actions:
- Type: forward
TargetGroupArn: !Ref TargetGroup
Conditions:
- Field: path-pattern
PathPatternConfig:
Values:
- "/*"
- Field: host-header
HostHeaderConfig:
Values:
- !Ref Domain
Outputs:
Alb:
Value: !Ref Alb
TargetGroup:
Value: !Ref TargetGroup
ターゲットグループはデフォルト403で返すようにして、Hostが指定したドメインの場合のみECSコンテナにアクセスさせるようにします。
定番のアクセス制限ですね。
余談ですが、サブネットにaがない理由はNatゲータウェイ作成時に下記エラーが出たためです。
“Nat Gateway is not available in this availability zone.”
長年AWS使っていて初めて遭遇しました…こんな事があるんですね。
調べたところAZには新旧あるようで、古いアカウントだと発生しうるようです。
何か対応すれば使えるようになるという類のものではなさそうなので、今回aは使用しないことにしました。
参考:
スタック作成
main.yml
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
Env:
Type: String
VpcId:
Type: String
IgwId:
Type: String
ServiceName:
Type: String
ServiceDirName:
Type: String
Domain:
Type: String
Certificates:
Type: String
TemplateBucketName:
Type: String
WebCpu:
Type: Number
WebMemory:
Type: Number
PublicCidrC:
Type: String
PublicCidrD:
Type: String
PrivateCidrC:
Type: String
PrivateCidrD:
Type: String
NginxLatestTag:
Type: String
PhpLatestTag:
Type: String
Resources:
#---ネットワーク割愛---
Ecr:
Type: AWS::CloudFormation::Stack
Properties:
Parameters:
Env: !Ref Env
ServiceName: !Ref ServiceName
TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/ecr.yaml'
Tags:
- Key: Name
Value: !Sub ${ServiceName}-${Env}
Alb:
Type: AWS::CloudFormation::Stack
DependsOn: Sg
Properties:
Parameters:
Env: !Ref Env
AccountId: !Ref "AWS::AccountId"
ServiceName: !Ref ServiceName
Domain: !Ref Domain
Certificates: !Ref Certificates
SgAlbId: !GetAtt Sg.Outputs.SgAlbId
VpcId: !Ref VpcId
PublicSubnetCId: !GetAtt Subnet.Outputs.PublicSubnetC
PublicSubnetDId: !GetAtt Subnet.Outputs.PublicSubnetD
TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/lb/alb.yaml'
Tags:
- Key: Name
Value: !Sub ${ServiceName}-${Env}
EcsTask:
Type: AWS::CloudFormation::Stack
DependsOn: Alb
Properties:
Parameters:
Env: !Ref Env
AccountId: !Ref "AWS::AccountId"
ServiceName: !Ref ServiceName
WebCpu: !Ref WebCpu
WebMemory: !Ref WebMemory
NginxLatestTag: !Ref NginxLatestTag
PhpLatestTag: !Ref PhpLatestTag
TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/task.yaml'
Tags:
- Key: Name
Value: !Sub ${ServiceName}-${Env}
Ecs:
Type: AWS::CloudFormation::Stack
DependsOn: EcsTask
Properties:
Parameters:
Env: !Ref Env
ServiceName: !Ref ServiceName
SgWebId: !GetAtt Sg.Outputs.SgWebId
TaskDefinition: !GetAtt EcsTask.Outputs.TaskDefinition
PrivateSubnetCId: !GetAtt Subnet.Outputs.PrivateSubnetC
PrivateSubnetDId: !GetAtt Subnet.Outputs.PrivateSubnetD
TargetGroup: !GetAtt Alb.Outputs.TargetGroup
TemplateURL: !Sub 'https://${TemplateBucketName}.s3.ap-northeast-1.amazonaws.com/${ServiceDirName}/templates/app/ecs.yaml'
Tags:
- Key: Name
Value: !Sub ${ServiceName}-${Env}
#---バッチ、スケーリング設定割愛
変更セット作成
CloudFormationには、create-stackコマンドが用意されていますが、今回はterraformっぽくdeployを使います。
aws cloudformation deploy \
--stack-name "$STACK_NAME" \
--template-file "./cf/$ENV/main.yaml" \
--parameter-overrides $(cat ./cf/$ENV/.params) \
--no-execute-changeset \
--profile "$PROFILE"
--no-execute-changesetを付与すると、変更セットの作成にとどめてくれます。
デプロイ
aws cloudformation deploy \
--stack-name "$STACK_NAME" \
--template-file "./cf/$ENV/main.yaml" \
--parameter-overrides $(cat ./cf/$ENV/.params) \
--profile "$PROFILE"
--no-execute-changesetを外しただけです。
ちなみに初回のデプロイに失敗した場合、削除せずに変更セットを再作成しようとするとエラーになります。デプロイ成功後2回目以降は削除不要で変更セットが作成できます。
削除
aws cloudformation delete-stack \
--stack-name "$STACK_NAME" \
--profile "$PROFILE"
感想
ymlファイルは非常にシンプルで作成しやすいです(CloudFromationはJsonでも可)。
しかし、変更セットはterraformに比べると差分がわかりにくかったです。システム規模が大きくなるとボトルネックになりそう…。
また、今回は、stacksetsを使わなかったので次回は使ってみたいです!