趣味で運営していたサービスたち,今まではEC2の上にdocker swarmで構築していたのだが,いよいよECSに載せ替えた.
- 基本terraform構築
- ALBでhost-based routing
- ECSに使うインスタンスはAutoScalingGroupとSpotFleetとのハイブリッド
- cronはECS ScheduledTaskに移動
みたいな感じになった.
動機
そもそもなんで変更する必要になったのかというと,
- iOS版Twitterクライアントのバックエンドサーバを閉じたのがきっかけ
- 自前Let's entryptがめんどくさい
- ReservedInstanceの期限が切れる
- 計算した感じRIよりSpotの方が安い
- デプロイ自動化したい
という流れ.
サーバを一つ閉じたことをきっかけに掃除がしたくなった.
目指す構成
ECS Clusterはひとつだけ作る.ALBもひとつだけ作り,全てhost-based routingで各ECS Serviceに振り分ける.
ECS Clusterの裏側はAutoScalingGroup半分,SpotFleet半分にしてある. このへんは状況によって変更できるようにしてあるが,普段はAutScalingGroupをほとんど使わずにほぼすべてのインスタンスをSpotFleetに寄せている.
仕事ならまずありえないが,そもそも俺の趣味サービスなので告知なしに落ちていても問題ない. お金をいただいているわけでもないし,お金を稼いでいるわけでもないので.
というわけで大部分をSpotFleetにしている.
terraform
以前からterraform管理だったが,今回の構成も全てterraformにしている.
プライベートリポジトリなので,残念ながら全貌を見せることはできないのだが…….
AutoScalingGroup
モジュールを作っている.
resource "aws_autoscaling_group" "asg" {
name = "${var.service}-${var.role}-${var.env}"
max_size = "${var.max_size}"
min_size = "${var.min_size}"
launch_configuration = "${aws_launch_configuration.as_conf.name}"
health_check_type = "EC2"
force_delete = true
vpc_zone_identifier = ["${var.subnet_ids}"]
desired_capacity = 0
lifecycle {
ignore_changes = ["desired_capacity"]
}
tag {
key = "Name"
value = "${var.service}-${var.role}-${var.env}"
propagate_at_launch = true
}
tag {
key = "service"
value = "${var.service}"
propagate_at_launch = true
}
tag {
key = "role"
value = "${var.role}"
propagate_at_launch = true
}
tag {
key = "env"
value = "${var.env}"
propagate_at_launch = true
}
tag {
key = "tfstate"
value = "${var.tfstate}"
propagate_at_launch = true
}
}
resource "aws_launch_configuration" "as_conf" {
image_id = "${var.ami}"
instance_type = "${var.instance_type}"
iam_instance_profile = "${var.iam_instance_profile}"
key_name = "${var.key_name}"
security_groups = ["${var.security_group_ids}"]
user_data = "${var.user_data}"
root_block_device {
volume_type = "gp2"
volume_size = "${var.volume_size}"
delete_on_termination = true
}
lifecycle {
create_before_destroy = true
}
}
launch_configurationに渡すuser_dataは外から変数でもらうことにしている.
SpotFleet
resource "aws_spot_fleet_request" "spot_fleet" {
iam_fleet_role = "${var.iam_fleet_role_arn}"
spot_price = "${var.max_spot_price}"
target_capacity = "${var.spot_target_capacity}"
valid_until = "${var.spot_valid_until}"
allocation_strategy = "${var.spot_allocation_strategy}"
terminate_instances_with_expiration = true
replace_unhealthy_instances = true
lifecycle {
ignore_changes = ["target_capacity"]
}
# subnet_idに${join(",", var.subnet_ids)}という渡し方は可能だが,それをやると裏側で勝手にsubnetの個数分のlaunch_specificationが自動生成される
# するとapply後に差分となってしまうので,仕方なくsubnetごとにlaunch_specificationを作る
launch_specification {
instance_type = "t2.micro"
ami = "${var.ami}"
key_name = "${var.key_name}"
placement_tenancy = "default"
iam_instance_profile = "${var.ec2_instance_profile_name}"
subnet_id = "${var.subnet_ids[0]}"
user_data = "${data.template_file.user_data.rendered}"
vpc_security_group_ids = ["${aws_security_group.instance.id}"]
weighted_capacity = 1
spot_price = "0.0152"
root_block_device {
volume_size = "${var.volume_size}"
volume_type = "gp2"
}
tags {
Name = "${var.service}-${var.role}-${var.env}"
service = "${var.service}"
role = "${var.role}"
env = "${var.env}"
tfstate = "${var.tfstate}"
}
}
launch_specification {
instance_type = "t2.small"
ami = "${var.ami}"
key_name = "${var.key_name}"
placement_tenancy = "default"
iam_instance_profile = "${var.ec2_instance_profile_name}"
subnet_id = "${var.subnet_ids[1]}"
user_data = "${data.template_file.user_data.rendered}"
vpc_security_group_ids = ["${aws_security_group.instance.id}"]
weighted_capacity = 2
spot_price = "0.0152"
root_block_device {
volume_size = "${var.volume_size}"
volume_type = "gp2"
}
tags {
Name = "${var.service}-${var.role}-${var.env}"
service = "${var.service}"
role = "${var.role}"
env = "${var.env}"
tfstate = "${var.tfstate}"
}
}
# 中略
}
launch_specificationはひとつずつ定義してやるしかない. 今回は,t2, m3, m4, m5あたりを織り交ぜた.
ECS Service, TaskDefinition
resource "aws_ecs_service" "service" {
name = "${var.service}-${var.role}-${var.env}"
cluster = "${var.ecs_cluster_id}"
task_definition = "${var.task_definition_arn}"
desired_count = "${var.desired_count}"
iam_role = "${var.iam_role_arn}"
launch_type = "EC2"
health_check_grace_period_seconds = "${var.health_check_grace_period_seconds}"
deployment_maximum_percent = "${var.deployment_maximum_percent}"
deployment_minimum_healthy_percent = "${var.deployment_minimum_healthy_percent}"
load_balancer {
target_group_arn = "${aws_lb_target_group.http.arn}"
container_name = "${var.container_name}"
container_port = "${var.container_port}"
}
ordered_placement_strategy {
type = "binpack"
field = "memory"
}
lifecycle {
ignore_changes = ["desired_count", "task_definition"]
}
}
ecs_serviceは,desired_countとtask_definitionをignoreしている. desired_countは,実際の負荷状況に応じて手動で変更したり,AutoScaleされたりすることを考えると,ignoreしておくべきである.
task_definitionは,デプロイのたびに新しいTaskDefinitionが登録され,それを元にデプロイされるため,ignoreしておかないと常に差分が出てしまう.
RDS
RDSは今回terraform管理にしていない. RDSはスナップショットから復元することがよくあるが,それをやるにはterraform管理されていると非常に不便である.
そのため,SecurityGroupやParameterGroupはterraform管理するが,RDSインスタンスそのものはterraform管理にしないことにしている.
ECS Scheduled Task
ECSでもcronっぽいことがいつの間にかできるようになっている!
これ,中身はCloudWatchEventで実現されているらしい. というわけでcloudwatch_eventを作れば良い.
resource "aws_cloudwatch_event_rule" "rss" {
name = "${var.service}-rss-${var.env}"
description = ""
schedule_expression = "cron(*/15 * * * ? *)"
}
resource "aws_cloudwatch_event_target" "rss" {
target_id = "${var.service}-rss-${var.env}"
arn = "${var.ecs_cluster_arn}"
rule = "${aws_cloudwatch_event_rule.rss.name}"
role_arn = "${var.ecs_events_role_arn}"
ecs_target = {
task_count = 1
task_definition_arn = "${aws_ecs_task_definition.task.arn}"
}
input = <<DOC
{
"containerOverrides": [
{
"name": "task",
"command": ["./manage.py", "runscript", "masuda.rss"]
}
]
}
DOC
lifecycle {
ignore_changes = ["ecs_target"]
}
}
ecs_targetをignoreしている. task_countを変更することはないだろうが,task_definition_arnは,前述の通りデプロイすれば変更される可能性はあるため,ignoreしておく.
Deploy
デプロイは全てCIから行う.
CircleCIユーザを作り,こんな権限を付与しておく.
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowUserToECSDeploy", "Effect": "Allow", "Action": [ "ecr:DescribeRepositories", "ecr:DescribeImages", "ecs:DescribeServices", "ecs:DescribeTaskDefinition", "ecs:RegisterTaskDefinition", "ecs:UpdateService", "ecs:RunTask", "ecs:DescribeTasks", "ecs:ListTasks", "iam:PassRole" ], "Resource": "*" } ] }
で,俺が作っているecs-goployというOSSでデプロイする.
ecs-deployでも良いのだが,ecs-deployは単発のタスク実行に対応していない.そのため,デプロイ直前にmigrationを流したいというような要求に答えられない.
そこで,自作したのがecs-goployなのだが,だいたいこんな感じでmigrationを流せる.
$ ./ecs-goploy task --cluster ${CLUSTER_NAME} --container-name task --image $AWS_ECR_REPOSITORY:$CIRCLE_SHA1 --timeout 600 --task-definition ${RUN_TASK_DEFINITION} --command "bundle exec rake db:migrate"
で,そのままデプロイもできる.
$ ./ecs-goploy service --cluster ${CLUSTER_NAME} --service-name ${SERVICE_NAME} --image $AWS_ECR_REPOSITORY:$CIRCLE_SHA1 --timeout 600 --enable-rollback --skip-check-deployments
SpotFleetのAutoScale
実際今回はAutoScaleさせていない. 個人サービスだし,ヤバイ時にスケールして金を浪費するより,潔く死んでくれていいと思っている.
どうしても落とせないサービスならそもそもAutoScalingGroup側でやるしね.
だけど,一応調べて実装してAutoScaleできるところまではやってみたので書いておく.
ApplicationAutoScaling
SpotFleetのAutoScalingはApplicationAutoScalingで実現rされている. これは,SpotFleet以外にも,ECS,Dynamodb等のAutoScaleを実現できる.
resource "aws_appautoscaling_target" "target" {
service_namespace = "ec2"
resource_id = "spot-fleet-request/${var.spot_fleet_request_id}"
scalable_dimension = "ec2:spot-fleet-request:TargetCapacity"
role_arn = "${var.role_arn}"
min_capacity = "${var.min_capacity}"
max_capacity = "${var.max_capacity}"
}
resource "aws_appautoscaling_policy" "scaling" {
service_namespace = "ec2"
name = "scale"
resource_id = "spot-fleet-request/${var.spot_fleet_request_id}"
policy_type = "TargetTrackingScaling"
scalable_dimension = "ec2:spot-fleet-request:TargetCapacity"
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "${var.predefined_metric_type}"
}
target_value = "${var.target_value}"
disable_scale_in = false
scale_in_cooldown = "${var.scale_in_cooldown}"
scale_out_cooldown = "${var.scale_out_cooldown}"
}
depends_on = ["aws_appautoscaling_target.target"]
}
だいたいこれでいける. predefined_metric_typeだけがちょっとむずかしくて,
DynamoDBReadCapacityUtilization, DynamoDBWriteCapacityUtilization, ALBRequestCountPerTarget, RDSReaderAverageCPUUtilization, RDSReaderAverageDatabaseConnections, EC2SpotFleetRequestAverageCPUUtilization, EC2SpotFleetRequestAverageNetworkIn, EC2SpotFleetRequestAverageNetworkOut, SageMakerVariantInvocationsPerInstance, ECSServiceAverageCPUUtilization, ECSServiceAverageMemoryUtilization
のどれかを選ぶ必要がある. https://docs.aws.amazon.com/sdkforruby/api/Aws/ApplicationAutoScaling/Types/PredefinedMetricSpecification.html
このmetricがtarget_valueに達した際に,scaleの判定が為される. policyをTargetTrackingにしているので,厳密な値定義やCloudWatchAlermの作成は必要ない.そのへんは裏側で勝手に作られる.
予想はできると思うが,service_namespaceやpredefined_metric_typeをecsのもに変更すれば,ほとんど同じ設定でECSのtask数もAutoScaleできる.
まとめ
土日で一気に載せ替えたので,並行稼働とかはやってない. 全部止めて,RDSをsnapshot復元して(VPCの移動があったため),一気に作ってつなげ変えた.
一応今のところ正常に動いているし,クラスタになったことでEC2のリソースを柔軟に使えるようになっている. ALBの代金が別途かかるにしても,EC2を少し削減できるようになった.
あと,m3.mediumのSpot価格が安すぎてやばい.これは嬉しい.
来月から少し安くなるといいなぁ.