趣味サービスのインフラをAWS ECSに載せ替えた

趣味で運営していたサービスたち,今まではEC2の上にdocker swarmで構築していたのだが,いよいよECSに載せ替えた.

  • 基本terraform構築
  • ALBでhost-based routing
  • ECSに使うインスタンスはAutoScalingGroupとSpotFleetとのハイブリッド
  • cronはECS ScheduledTaskに移動

みたいな感じになった.

動機

そもそもなんで変更する必要になったのかというと,

  1. iOSTwitterクライアントのバックエンドサーバを閉じたのがきっかけ
  2. 自前Let's entryptがめんどくさい
  3. ReservedInstanceの期限が切れる
  4. 計算した感じRIよりSpotの方が安い
  5. デプロイ自動化したい

という流れ.

サーバを一つ閉じたことをきっかけに掃除がしたくなった.

目指す構成

f:id:h3poteto:20180624222257p:plain

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でデプロイする.

github.com

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価格が安すぎてやばい.これは嬉しい.

来月から少し安くなるといいなぁ.