趣味で運営していたサービスたち,今までは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価格が安すぎてやばい.これは嬉しい.
来月から少し安くなるといいなぁ.