Amazon EC2 Auto Scaling を Terraform で素振り

Amazon EC2 Auto Scaling を ユーザーガイド を読みながら Terraform で素振りしたメモ。残骸はこちら

Launch Configuration と Launch Template

Auto Scaling で起動する EC2 インスタンスを定義する方法は下記の複数があります。

  • Launch Configuration
  • Launch Template

Launch Configuration より Launch Template のほうが後発で、以下のような違いがあります。

  • Launch Configuration は Auto Scaling 専用なのに対して Launch Template は他の用途にも使用できる
    • Launch Template から直で EC2 インスタンスを作成したりできる
  • Launch Template はバージョン管理されるので更新すると新たにバージョンが作成される
    • Launch Configuration はバージョン管理されないので更新時に新旧維持するためには別名で作成するしか無い

また、Launch Template でしかできないことがいくつかあります。基本的に Launch Template を使っていれば良さそうです。

ルートデバイス

Launch Configuration なら Terraform で root_block_device でルートデバイスが指定できますが Launch Template だと block_device_mappings でデバイス名を明示的に指定する必要があります。

最初 CentOS 向けに /dev/sda1 で Launch Template を作成していましたが、AMI を Amazon Linux 2 ベースに変更したところインスタンスが開始できなくなりました。Amazon Linux 2 だとルートデバイスが /dev/xvda になるためです。

トラブルを避けるために Launch Template で block_device_mappings でルートデバイスを指定していろいろ設定するのはやめて、あらかじめカスタムも AMI にブロックデバイスの情報を程よく刻んでおくのが良いかもしれません。もっともそんな間違いは滅多に無いだろうし、間違えたとしても直ぐに気づくだろうので気にしなくても良いかもしれません。

下記のように AMI の block_device_mappings を参照すれば良いかも。

data "aws_ami" "app" {

  // snip

}

resource "aws_launch_template" "app" {

  // snip

  block_device_mappings {
    device_name = "${lookup(data.aws_ami.app.block_device_mappings[0], "device_name")}"

    ebs {
      volume_size           = 40
      volume_type           = "gp2"
      delete_on_termination = true
    }
  }
}

Blue/Green Deployment

無停止でインスタンスの AMI を更新する方法はいくつか考えられます。

  • ELB までをワンセットにした Blue/Green Deployment で DNS を切り替える
  • AutoScaling Group までをワンセットにした Blue/Green Deployment で ELB を切り替える
  • Launch Configuration/Template までをワンセットにして AutoScaling Group 内のインスタンスを Rolling Update

最後の方法は Terraform 単体ではたぶん無理です。DNS を切り替える方法は TTL の影響を受けるし Route53 も必要なので、ELB を切り替える方法を試しました。

AutoScaling Group が参照する Launch Configuration を変更したとしても起動済の EC2 インスタンスには影響しません。その AutoScaling Group で新しく起動するインスタンスでのみ新しい Launch Configuration で起動します。なので AMI 更新時に AutoScaling Group はリソースが再作成されるようにする必要があります。これは AutoScaling Group の name に Launch Configuration の name を含めればよいです、後述の通り Launch Configuration の変更時には別名で再作成させるため、Launch Configuration の name が変われば AutoScaling Group の name も変わって、AutoScaling Group も再作成になります。

AutoScaling Group が再作成されるとき、デフォだと「古いリソースの削除→新しいリソースの作成」の順番で処理されます。これだと再作成するときに一時的にインスタンスが全滅してしまうので lifecyclecreate_before_destroy = true を指定して「新しいリソースの作成→古いリソースの削除」の順番で処理されるようにします。

Launch Configuration は作成後に更新ができず、変更があるときはリソースの再作成が必要になります。再作成時に lifecyclecreate_before_destroy = true を指定していると一時的に新旧のリソースが同時に存在するため名前の重服を避けるための name_prefix を設定します。これで Launch Configuration の name は Terraform がタイムスタンプを元に自動生成します。

AutoScaling Group と ELB のターゲットグループは aws_autoscaling_grouptarget_group_arns で行う必要があります。aws_autoscaling_attachment リソースでも紐付けできますが、aws_autoscaling_attachment だとデプロイ時に一時的に 503 になる期間が生じます。aws_autoscaling_attachment だと新しい AutoScaling Group が作成された後に ELB にアタッチされるため、ヘルスチェックのタイプが作成直後は EC2 になります。そのため EC2 インスタンスのステータスが OK になった時点で 古い AutoScaling Group が ELB からデタッチされるため、その後、新しい AutoScaling Group を ELB にアタッチしして新しい EC2 インスタンスで Web サーバの準備が終わって ELB のヘルスチェックが完了するまでの期間がダウンタイムになります。

まとめると、次のようなリソース定義になります。

resource "aws_launch_configuration" "app" {
  name_prefix     = "hello-asg-app-"

  // snip

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_autoscaling_group" "app" {

  name                 = "${aws_launch_configuration.app.name}"
  launch_configuration = "${aws_launch_configuration.app.name}"

  // snip

  health_check_type   = "ELB"
  target_group_arns = ["${aws_lb_target_group.app.arn}"]

  lifecycle {
    create_before_destroy = true
  }
}

なお、この方法だとひとつの ELB に新旧両方の AutoScaling Group がアタッチされている期間があるため、ELB からの転送先に一時的に新旧が混在します。

Launch Template を使う場合は Launch Template がバージョニングをサポートしているので、aws_launch_configurationname_prefixcreate_before_destroy のような仕込みは不要です。ただし Launch Template の更新時に AutoScaling Group の再作成を行わせる必要があるのは変わらないので、AutoScaling Group の name には Launch Template のバージョンを含めます。

resource "aws_autoscaling_group" "app" {

  name = "hello-asg-app-${aws_launch_template.app.latest_version}"

  launch_template = {
    id      = "${aws_launch_template.app.id}"
    version = "${aws_launch_template.app.latest_version}"
  }

  health_check_type   = "ELB"
  target_group_arns = ["${aws_lb_target_group.app.arn}"]

  lifecycle {
    create_before_destroy = true
  }
}

スケーリングポリシー

メトリクスに基づいた、いわゆるオートスケーリングの設定には下記の3つのポリシーが使用できます。

  • ターゲット追跡スケーリング
  • ステップスケーリング
  • 簡易スケーリング

ターゲット追跡スケーリングが一番後発です。基本的にターゲット追跡スケーリングを使っておけば OK です。

ターゲット追跡スケーリング

指定したメトリクスが指定した値に近づくように自動的にスケーリングします。メトリクスには以下の事前定義されたメトリクスが指定できます。

  • ターゲット別の Application Load Balancer リクエストの数
  • CPU の平均使用率
  • 平均ネットワーク入力 (バイト)
  • 平均ネットワーク出力 (バイト)

スケーリングポリシーを設定すると、マネジメントコンソールからは変更や削除ができない CloudWatch アラームが自動で作成されます。例えば「CPU の平均使用率」を 40 に設定すると次のような CloudWatch アラームが自動的に作成されます。

  • CPUUtilization > 40 for 3 datapoints within 3 minutes
  • CPUUtilization < 36 for 15 datapoints within 15 minutes

この2つの閾値に収まるようにインスタンス数が AutoScaling Group の Min と Max の間で自動的に調整されます。さらに CPUUtilization < 36 のアラームはCPU使用率がほぼゼロのまま放置しているとさらに低い値に自動的に変化したので、スケーリング閾値のアラーム自体も自動で調整されるようです。

マネジメントコンソールからは事前定義済の前述の4つのメトリクスしか選択できませんが、任意のメトリクスが指定できます。ただし、AutoScaling Group のインスタンス数に対して逆相関(インスタンスが増えれば値が減る)なメトリクスでなければ意味がありません。

Terraform で事前定義済のメトリクスを指定するには次のようにします。

resource "aws_autoscaling_policy" "cpu" {
  autoscaling_group_name = "${aws_autoscaling_group.app.name}"
  name                   = "cpu"
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }

    target_value = 40.0
  }
}

同じメトリクスをカスタムメトリクスとして指定するなら以下のようになります。

resource "aws_autoscaling_policy" "cpu" {
  autoscaling_group_name = "${aws_autoscaling_group.app.name}"
  name                   = "cpu"
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    customized_metric_specification {
      namespace   = "AWS/EC2"
      metric_name = "CPUUtilization"
      statistic   = "Average"

      metric_dimension {
        name  = "AutoScalingGroupName"
        value = "${aws_autoscaling_group.app.name}"
      }
    }

    target_value = 40.0
  }
}

簡易スケーリングとステップスケーリング

簡易スケーリングとステップスケーリングは、スケーリングポリシーと、スケーリングポリシーをアクションとして呼び出す CloudWatch アラームを別々に作成する必要があります。

簡易スケーリングとステップスケーリングはよく似ていて、簡易スケーリングがスケーリングの調整値(インスタンスをどれだけ追加するか、とか)が1段階しか設定できないのに対して、ステップスケーリングならアラームを超過した量に応じて、段階的にスケーリング調整値を指定できます(閾値を超えたら 4 台追加するけれども 20% 超えてるなら一気に 10 台追加する、とか)。

なお、ステップスケーリングのみ metric_aggregation_type が指定できます、が、何に使われるものなのかわかりません・・・。対応するアラームの statistic と同じものを指定しておけばよいのかな? なお、API リファレンスでは metric_aggregation_type には Minimum/Maximum/Average のいずれかしか指定できないことになっているのですが実際には Sum も指定できるようです。SampleCount は指定できないようです。

スケジュールに基づくスケーリング

指定された間隔や cron 式による時刻に AutoScaling Group の Desired Capacity や Min Size や Max Size を変更します。特定の曜日や特定の時間帯だけスケールインさせたりできます。

詳細モニタリング

スケーリングポリシーを設定するなら、詳細モニタリングを有効にして1分ごとにメトリクスが記録されるようにし、迅速にスケーリングできるようにしておくと良いです。

resource "aws_launch_template" "app" {

  // snip

  monitoring {
    enabled = true
  }
}

クールダウンとウォームアップ

クールダウンは手動スケーリングや簡易スケーリングポリシーで適用される。クールダウン期間中は簡易スケーリングポリシーによるスケーリングが発生しなくなる。

手動スケーリングではクールダウンをなしにするか Group に設定されたデフォルトのクールダウン期間を用いるかを選択できる。簡易スケーリングポリシーではスケーリング固有のクールダウン期間を指定するか、Group に設定されたデフォルトのクールダウン期間を用いるかを選択できる。

ウォームアップはターゲット追跡スケーリングとステップスケーリングで適用される。ウォームアップ期間中のインスタンスはスケールアウトの調整値を適用するときの元数として使用されない。例えば、現在5台のインスタンスが起動中で、うち2台がウォームアップ期間だとすると、このときにインスタンスを3台追加する調整値のスケーリングポリシーが実行されたとしても、2台がウォームアップ中なので1台しか追加されない。

ライフサイクルフック

ELB にアタッチされる AutoScaling Group の場合は前述の通り ELB の転送先の切り替えによる Blue/Green Deployment で無停止でアップデートできます。

Terraform が AutoScaling Group を再作成するとき、Terraform は新しい AutoScaling Group が利用可能になるまで待機してから古い AutoScaling Group を削除します。AutoScaling Group のヘルスチェックタイプを ELB にすれば AutoScaling Group が利用可能かの判断は ELB のヘルスチェックが Healthy となったときになるため、Terraform が AutoScaling Group を再作成するとき、新しいインスタンスが ELB で Healthy になるまで Terraform は待機します。

一方、ELB が絡まないワーカーのようなインスタンスだと前述のような ELB のヘルスチェックが使えないため簡単ではありません。EC2 インスタンスのステータスが OK となった時点で Terraform が新しい AutoScaling Group を利用可能として待機が終わってしまうためです。

ライフサイクルフックを使えばインスタンスのステータスが OK となっただけでは利用可能とはならず、準備が整った任意のタイミングまでインスタンスが利用可能と判断されるのを待機できるので、ワーカーのようなインスタンスでも Blue/Green Deployment できます。

例えば、下記のようなスクリプトをユーザーデータに設定し、インスタンスの開始後に準備が整うまで利用可能になるのを待機させることができます。

#/bin/bash

set -eux

# インスタンスメタデータからインスタンスIDとリージョンを取得
instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
region=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/.$//')

# インスタンスのタグから AutoScaling Group を取得
group_name=$(aws ec2 describe-instances \
  --region "$region" \
  --instance-ids "$instance_id" \
  --query 'Reservations[].Instances[].Tags[?Key==`aws:autoscaling:groupName`][].Value' \
  --output text)

# ここでなにか処理して準備ができるまで待つ

# complete-lifecycle-action を呼び出してライフサイクルを続行する
# この処理が呼ばれるまでは新しいインスタンスは利用可能とならないので
# Terraform も待機される
aws autoscaling complete-lifecycle-action \
  --region "$region" \
  --auto-scaling-group-name "$group_name" \
  --lifecycle-hook-name "hello-asg-app-launching" \
  --lifecycle-action-result "CONTINUE" \
  --instance-id "$instance_id"

ただ、インスタンスが生きたままインスタンスの中のサービスが死んでも AutoScaling による故障が検出されないので、ヘルスチェックも独自に実装して適宜 aws autoscaling set-instance-health でインスタンスを Unhealthy にするか、software watchdog timer などでインスタンスを停止するなどの対策が必要になるかもしれません。

Application AutoScaling

今回試したものは EC2 インスタンスをスケーリングするための EC2 Auto Scaling ですが、その他の AWS リソースのスケーリングのための Application AutoScaling というものもあります。

Packer

カスタム AMI を作成するために Packer を使いました。手作業や AWS CLI で頑張るよりはだいぶ楽だと思います。それでも Docker イメージを作成するのと比べればそうとう手間ですが。

さいごに

頻繁に更新されるシステムで都度 AMI を作り直してデプロイするのは時間もかかるし検証も面倒そうなので、AMI を更新するのはミドルウェア構成が変わったときだけにして、アプリのソースは tar.gz で固めて S3 に置くなどしてインスタンスの開始時に S3 から展開してサービスを開始、アプリの更新時は AMI はそのままでアプリのソースだけ S3 経由でデプロイするのが運用しやすそうです(ミドルウェアレイヤーだけイミュータブルなイメージ)。

また、スケーリングポリシーはそれが必要となるようなシステムに成長するかどうかはやってみなければわからないだろうので最初は設定せず、最初は必要に応じて手動スケーリングで良いだろうと思います。EC2 インスタンスは AWS 側の都合でわりと突然死することがあるので AutoScaling でインスタンス数の維持(故障したら自動で再作成)だけでも十分有用です。

ただ、AutoScaling のために AMI をうまく作るために変に消耗するぐらいなら、ECS とかで Docker イメージをアプリのコード込でデプロイするのでも良いように思います。