🎚️

ECS Fargateの料金・キャパシティプロパイダ戦略・構築について

2021/02/06に公開

先日、ECS + EC2 で動作させていたWebアプリケーションを、ECS + Fargateでの動作に変更して、今の所元気よく動いてくれています。今回は、Fargateに変更するにあたって、実際に手を動かしたり、サポートなどに問い合わせて判明したことなどをここに知見としてまとめたいと思います。

以前、以下のような記事を書きましたが、現在の状況に合わせてパワーアップしたものとしております。

https://qiita.com/ooharabucyou/items/77c2fc76f268d2acf6da

Fargate上で動くアプリケーションというよりは、ECSとFargateの挙動そのものが話題の中心となります。

こちらで記載している情報については、2021年2月4日現在のものです。

この記事の想定読者

  • ECSのFargateを利用してWebサービスを構築しようとしている人
  • Fargateの料金形態が分散しててよくわからん!と、思っている人
  • AWS初心者向けではないかもです
  • Dockerについての解説などはしません

コンテナが動作する場所と料金

ECS は、Docker のコンテナの管理をしてくれる便利なサービスですが、どこで動かすか? については、3つの選択肢があります。

  • EC2: EC2に、ECSコンテナーエージェントをインストールして、ECSに登録する方式です。ECSリリース当初からあり、利用する前にEC2のサイズや、EC2自体のスケールアウトについて考えておく必要があり、少し面倒です。
  • Fargate: ホストマシーンについては意識する必要がなく、vCPUと、メモリの量で課金されるため、お気軽にスタートできます。
  • Fargate Spot: Fargateバージョンのスポットインスタンスのようなものです。時々タスクが落ちることがあります。開発環境や、一時的にサーバを増やしたいときに便利そうです。

今回は、Fargate / Fartege Spot について考えます。

料金

東京リージョンの料金は以下になっています。
Fargateはタスク起動時に、vCPUとメモリのサイズを指定する形になっていて、秒単位で計算されます。

キャパシティプロパイダ vCPU1あたり/時間 メモリ1GBあたり/時間
Fargate 0.05056ドル 0.00553ドル
Fargate Spot 0.02630262ドル 0.00287685ドル

仮にFargateでvCPUが1、メモリが2GBのタスク1つを、30日間起動させると、

(0.05056 * 1 + 0.00553 * 2) * 24 * 30 = 44.3664ドル

となります。

ご覧の通り、Fargate Spot のほうが48%OFFとなっています。安い!

安く抑えるなら Savings Plans の利用を考える

Fargateについては、年間で利用する前提であれば、Savings Plans の利用を考えると、より安くなりそうです。

公式のページの料金を比べるのが少し大変なので、ここにまとめてみました。

キャパシティプロパイダ vCPU1あたり/時間 メモリ1GBあたり/時間 オンデマンド払いからの割引率
オンデマンド 0.05056ドル 0.00553ドル -
1年前払いなし 0.042976ドル 0.0047005ドル 15%
1年前一部前払い 0.040448ドル 0.004424ドル 20%
1年前払い 0.0394368ドル 0.0043134ドル 22%
3年前払いなし 0.030336ドル 0.003318ドル 40%
3年一部前払い 0.027808ドル 0.0030415ドル 45%
3年前払い 0.0267968ドル 0.0029309ドル 47%

なお、ここは念の為AWSサポートに聞いて確認したのですが、Fargate Spotの場合は、Savings Plan による割引は適用されません
見積もり時に、計算を間違えたり (1敗), Savings Plan で余計な買い物をしないようにしましょう。

Fargateは3年前払いにしたとしても、Fargete Spotのほうが安いので、うっかり止まっても問題のない開発環境や、検証環境などはFargate Spotを使うべきでしょう。

Savings Planの契約期間の長さや、前払いを使うかどうかは組織の状態によりけりかと思います。

構成

Fargateを利用する場合は、必然的にVPC上にサービスを展開する必要があります。
Public IPを割り振ることはできますが、Webアプリケーションのオートスケールなどを考えた場合、Public SubnetにALBを置き、アプリケーション自体は、Private Subnet 上で動作するように設定したほうが良さそうです。

この記事で説明するためのサンプルとして、以下のような構成を作りました。
Availability Zone も2つ使う前提で分散する形になります。
また、Fargateで起動するタスクについては、負荷(CPU利用率)に応じて、タスク数を最低2つから、4つ(指定した数)まで、自動的にスケールアウトするように設定します。

このサンプルでは、NAT Gateway は、1つのAZにしか設定していないため、ここが単一障害点になりますのでご注意ください。

キャパシティプロパイダ戦略

キャパシティプロパイダ戦略は、ECSクラスタの中で、Fargateや、Faragte Spot
(もしくは、EC2のスケーリンググループ上で) どのくらいの割合で起動するかというのを設定することができるものです。

クラスメソッドのブログ記事が参考になります。

https://dev.classmethod.jp/articles/regrwoth-capacity-provider/

Base と Weight という2つの値から成り立っていて、Baseは最小起動数で、Weight は比率を表しています。

上記の記事では、Baseについて

注意:run-taskのみ有効、create serviceでは無効(将来対応予定)

という記述があり。サービスでは未対応とありますが、現在は対応されているようです。

キャパシティプロパイダ Base Wight
Fargate 2 1
Fargate Spot 0 1

のように設定して、最小実行タスク数を2に設定したサービスを作ると、2タスクがFargateで起動されて、Fargete Spotでは起動されません。
3タスク目は、Fargateで起動して、4タスク目はFargete Spot、5タスク目はFargete Spot... という形で起動します。

テスト用の環境の用に、いつタスクが終了してしまっても支障がない状況の場合は、以下のように
設定して、すべてがFargate Spotで起動するようにすることも可能です。

キャパシティプロパイダ Base Wight
Fargate 0 0
Fargate Spot 0 1

この場合に、サービスを1タスクのみで運用する場合は、サービスが中断してしまう可能性があるので、中断しては困るアプリケーションの場合は複数タスク用意するか、Fargeteを利用しましょう。

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/fargate-capacity-providers.html#fargate-capacity-providers-termination

サービスの一部として Fargate Spot を使用する場合、サービススケジューラは中断信号を受信し、キャパシティーが利用可能な場合に Fargate Spot で追加のタスクを起動しようとします。タスクが 1 つしかないサービスは、キャパシティーが利用可能になるまで中断されます。

デプロイ

長々と説明しましたが、諸々なツールを駆使して構築していきましょう。
サンプルでは、phpinfo() を実行するPHPスクリプトが置かれているだけのPHPアプリケーションの
イメージを作り、コンテナを起動します。

アプリケーションの実行に利用する環境変数については、3つの方法を利用して設定しています。
自分の中では以下のように使い分けています。

  • S3に置く .env ファイル: いつも読み込む必要がある固定の値で、かつ万が一中身が漏洩しても問題ないもの。
  • タスク定義でのハード設定: ビルドごとに違う値にしたい場合に使うもの。CircleCIのビルドごとの固有な値 ($CIRCLE_SHA1)を渡して、Sentry のログ追跡用のIDとして利用したりしています。
  • SSMのsecrets value: アプリケーション連携で使うトークンなど、漏洩したらまずいもの。

今回のサンプルでは、S3からの環境変数取得は行っていません。

事前準備

サンプルコードでは、SSMのパラメーターストアを利用して、機密情報を含んだ環境変数の埋め込みをおこなています。
テスト用に、SSMのパラメータストアにSecureString値をセットします。
名前は、/my-sample-app/default/sample として、値はテストなので、漏れても問題ない値にしましょう。(今回は、phpinfoで環境変数を確認するので、見えてしまいます。)

また、上記で設定したときに使ったKMSのキーについての情報がいります。
デフォルトでは、aws/ssm が使われますので、KMSの管理画面から、エイリアスが aws/ssm となっているものを探し、ARNをコピーします。

terraform を利用した環境の構築

今回考えた環境を準備するには、ALBを用意したりVPCを用意したりなど、やや面倒なため
terraform を使って構築します。

この記事では、terraform 0.14.x を利用しています。

サンプル用に作った terraform 設定である
https://github.com/kawahara/ecs-fargate-autoscale
を Clone します。

以下で、環境を構築をします。aws configure で、クレデンシャルの設定を
行っている前提となります。

terraform init
AWS_DEFAULT_REGION='ap-northeast-1' TF_VAR_ssm_kms_arn='SSMに利用しているKMSキーのARN' terraform apply

これにより以下を行います。

  • VPC・Subnetの作成
  • NAT Gatwayの作成
  • ALBの作成
  • ECSクラスタ・サービス・タスク定義の作成
  • ECRレポジトリの作成
  • オートスケールの設定

IAM Role について

ECS+Fargateでサービス作るときに考えなくてはならないIAM Roleは最低限2つあります

  • タスク実行ロール: Fargate上でタスクを実行しようとするときに使うRoleです。ECRからImageを取得したり、SSMからデータを取り出したりなどが必要です。
  • タスクロール: Fargate上で動くアプリケーションが必要とするロールです。S3へのアクセスなどについての権限を持ちます。

似たような名前でややこしいですね!!

今回のサンプルでは、タスク実行ロールについては、SSMからパラメータを取得し、KMSを利用して復号する必要があるので、以下のように設定してます。
もし環境変数の設定について、S3にあるファイルから行う場合は、当該のファイルへのアクセスを
許可する設定も行う必要があります。

タスク実行ロール設定の解説
# 実行ロールの定義
# ECSで利用することを明示する必要があります。
resource "aws_iam_role" "execution" {
  name = "my-sample-app-execution-${terraform.workspace}"
  path = "/my-sample-app/${terraform.workspace}/ecs/"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ExecutionRole",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF
}

# AmazonECSTaskExecutionRolePolicy を Role にアタッチします。
# このポリシーは、ECRからImageを取得する権限や、CloudWatchにログを出力する
# 権限が含まれています。
resource "aws_iam_role_policy_attachment" "execution" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# SSM ParameterStore の機密情報にアクセスするための
# ポリシーを作るためのdataです。
# KMSでのDecryptを行うため、適用時にKMSのARNが必要になります。
data "aws_iam_policy_document" "ecs_get_ssm" {
  statement {
    sid = "KSMDecrypt"
    actions = [
      "kms:Decrypt"
    ]
    resources = [
      var.ssm_kms_arn
    ]
  }

  statement {
    sid = "SSMGetParameters"
    actions = [
      "ssm:GetParameters"
    ]
    resources = [
      "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter/my-sample-app/${terraform.workspace}/*"
    ]
  }
}

# 先程作ったSSM ParameterStore にアクセスするための設定をポリシーとして定義します。
resource "aws_iam_policy" "ecs_execution_ssm" {
  name        = "my-sample-app-execution-ssm-${terraform.workspace}"
  description = "read permission to fetch ssm secret params"
  policy      = data.aws_iam_policy_document.ecs_get_ssm.json
}

# SSMにParameterStoreにアクセスするためのポリシーを、ロールに設定します。
resource "aws_iam_role_policy_attachment" "ecs_execution_ssm" {
  role       = aws_iam_role.execution.name
  policy_arn = aws_iam_policy.ecs_execution_ssm.arn
}

今回のサンプルでは設定しませんでしたが、タスクロールが必要な場合は以下のような設定を行います。
以下の例では、アプリケーションが my-bucket というS3バケットの中に入っているデータへの
アクセスを可能にします。

タスクロール設定の解説
resource "aws_iam_role" "task" {
  name = "my-sample-app-task-${terraform.workspace}"
  path = "/my-sample-app/${terraform.workspace}/ecs/"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TaskRole",
      "Effect": "Allow",
      "Principal": {
        "Service": "ecs-tasks.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
EOF

data "aws_iam_policy_document" "task_s3" {
  statement {
    sid = "S3ListBucket"
    actions = [
      "s3:ListBucket"
    ]
    resources = [
      "arn:aws:s3:::my-bucket"
    ]
  }

  statement {
    sid = "S3GetObject"
    actions = [
      "s3:GetObject"
    ]
    resources = [
      "arn:aws:s3:::my-bucket/*"
    ]
  }
}

resource "aws_iam_policy" "task_s3" {
  name        = "my-sample-app-task-s3-${terraform.workspace}"
  description = "S3 data read policy"
  policy      = data.aws_iam_policy_document.task_s3.json
}

resource "aws_iam_role_policy_attachment" "task_s3" {
  role       = aws_iam_role.task.name
  policy_arn = aws_iam_policy.task_s3.arn
}

オートスケールの設定

オートスケール設定を行っておくと、自動的に負荷が上がったときに、タスク数を自動調整してくれるので便利です。

オートスケール設定の解説
# オートスケールターゲットを作成
resource "aws_appautoscaling_target" "ecs" {
  service_namespace  = "ecs"
  resource_id        = "service/${aws_ecs_cluster.cluster.name}/${aws_ecs_service.service.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  min_capacity       = var.ecs_autoscale.min
  max_capacity       = var.ecs_autoscale.max
}

# オートスケールポリシーの設定: スケールアウト
resource "aws_appautoscaling_policy" "out" {
  name               = "my-sample-app-out-${terraform.workspace}"
  resource_id        = "service/${aws_ecs_cluster.cluster.name}/${aws_ecs_service.service.name}"
  scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
  service_namespace  = aws_appautoscaling_target.ecs.service_namespace

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 300
    metric_aggregation_type = "Average"

    step_adjustment {
      metric_interval_upper_bound = 0
      scaling_adjustment          = 1
    }
  }

  depends_on = [aws_appautoscaling_target.ecs]
}

# オートスケールポリシーの設定: スケールイン
resource "aws_appautoscaling_policy" "in" {
  name               = "my-sample-app-in-${terraform.workspace}"
  resource_id        = "service/${aws_ecs_cluster.cluster.name}/${aws_ecs_service.service.name}"
  scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
  service_namespace  = aws_appautoscaling_target.ecs.service_namespace

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 300
    metric_aggregation_type = "Average"

    step_adjustment {
      metric_interval_upper_bound = 0
      scaling_adjustment          = -1
    }
  }

  depends_on = [aws_appautoscaling_target.ecs]
}

# スケールアウト発動条件の設定 当該サービスのCPU利用率が75%を超えた場合
resource "aws_cloudwatch_metric_alarm" "out" {
  alarm_name          = "my-sample-app-${terraform.workspace}-ecs-cpu-gt-75"
  comparison_operator = "GreaterThanOrEqualToThreshold"
  evaluation_periods  = "1"
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = "300"
  statistic           = "Average"
  threshold           = "75"

  dimensions = {
    ClusterName = aws_ecs_cluster.cluster.name
    ServiceName = aws_ecs_service.service.name
  }

  alarm_actions = [aws_appautoscaling_policy.out.arn]
}

# スケールイン発動条件の設定 当該サービスのCPU利用率が25%未満だった場合
resource "aws_cloudwatch_metric_alarm" "in" {
  alarm_name          = "my-sample-app-${terraform.workspace}-ecs-cpu-lt-25"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = "1"
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = "300"
  statistic           = "Average"
  threshold           = "25"

  dimensions = {
    ClusterName = aws_ecs_cluster.cluster.name
    ServiceName = aws_ecs_service.service.name
  }

  alarm_actions = [aws_appautoscaling_policy.in.arn]
}

試しにImageをPush

terraform による構築では、DockerのImageを用意してくれるわけではないので、
現状のままだと、ECSサービスがタスクを起動してくれません。

ECRの画面に行き、my-sample-app/default/server というリポジトリが用意されているため
ImageをPushしてください。

サンプルでは、docker ディレクトリに、ただPHP8+Apacheを動かすだけのイメージを作るための
Dockerfile が置いてあるので、イメージを作り latest というタグをつけてpushします。

これにより、2つのタスクが自動的に起動します。

また、ALBのエンドポイントの /sample.php にアクセスすると、おなじみ phpinfo が見られるのも確認できます。
確認のポイントとして

  1. リロードするたびに、Hostname が変わっている: 2タスクあるのでALBが分散してくれている
  2. $ENV['MY_SECURE_VALUE'] に、先程設定した値が表示されている

などを見ると良さそうです。

サンプルの、キャパシティプロパイダ戦略は以下のようになっているため、
タスクの詳細を確認すると、1タスクはFargateで、1タスクはFargate Spotで起動していることも
確認できるでしょう。

キャパシティプロパイダ Base Wight
Fargate 0 1
Fargate Spot 0 1

タスク1つめ

タスク2つめ

キャパシティプロパイダ戦略の動きを確認するために、サービスの更新を行い、AutoScaleの値や、設定の比率などを変えることでタスクがどのように起動するのかを見てみると、感覚がつかめるでしょう。

Availability Zone の分散について

さて、ドキュメントによると

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-placement.html

デフォルトでは、Fargate タスクはアベイラビリティーゾーン間で分散されます。

とありますが、キャパシティプロパイダ戦略の設定によってはそうならないケースもあるようです。

例えば、以下のようにキャパシティプロパイダ戦略を設定した場合を想定します。

キャパシティプロパイダ Base Wight
Fargate 0 1
Fargate Spot 0 1

ap-northeast-1a(1a), ap-northeast-1c(1c) のAZが使えるVPCの中で2つのタスクを起動したとします。
このとき、ALBのターゲットグループを確認すると、両方とも 1a もしくは、1c で用意される場合があるのが確認できると思います。

キャパシティプロパイダ Base Wight
Fargate 2 1
Fargate Spot 0 1

のような状況で、2タスク起動した場合は、1a, 1cでそれぞれ1つづつFargateにて起動されるようです。

異なるキャパシティプロパイダでタスクを同時起動しようとした場合はAZの分散がうまく行かないケースがあるようです。
サポートに相談したところ、この辺の細かい挙動についての公開資料はなく、うまく分散させるには、今の所以下で行う必要がありそうとのことでした。

  • BaseをAZ数以上にする: 無難に本番環境はFargateのBaseを2にしました。
  • 同時にタスクを起動しないようにする
  • AZ数より多い数のタスクを起動するようにする

ここは改善されるか、細かい挙動が公開されると嬉しいですね。

サンプル環境の削除

今回のサンプル環境を削除するためには、以下のコマンド実行します。

AWS_DEFAULT_REGION='ap-northeast-1' TF_VAR_ssm_kms_arn='SSMに利用しているKMSキーのARN' terraform destroy

たのしいFargateライフを!

この記事では、構築,料金, オートスケールと分散に関する話題を中心に考えてみました。
EC2での構築はGPUなどを使いたい場合や、ネットワークの設定によっては必須となりますが、EC2インスタンス自体の管理がなかなか面倒です。
単純なWebアプリケーションであれば、Fargateでシンプルな運用が実現できそうです!

Discussion