LoginSignup
15

More than 1 year has passed since last update.

Fargate Spot の安全な運用方法を考える 中断編

Last updated at Posted at 2022-05-02

キャパシティ編もよかったらどうぞ

はじめに

Amazon ECS と AWS Fargate を使ってサービスを提供しているときに、コストを最適化する方法があります。一例として、次のコスト最適化の方法を挙げます。

  • Fargate Spot : 空きキャパシティを利用
  • Savings Plans : 1年 or 3年の期間契約

上記の2個は Fargate の購入方法が異なります。Fargate Spot は通常の価格と比べて最大 70% OFF の価格で利用できます。EC2 スポットインスタンスと同様の概念になっていて、Fargate Spot も AWS の空きキャパシティを利用します。Fargate Spot の空きキャパシティを確保できるかぎり、タスクとしてコンテナ群を動かすことが出来ますが、空きキャパシティが確保できなくなった場合にタスクが中断する可能性があります。タスクで動かしているアプリケーションは、中断する2分前に中断通知(SIGTERM)を受け取ることが出来ます。SIGTERM を受け取り、正しく処理が完了できるようなアプリケーションは Fargate Spot に向いていると言えます。

Savings Plans は、1年 or 3年の期間を指定した購入方法です。指定した期間の利用を約束する購入方法となっており、途中で使わなくなっても料金が発生する仕組みです。その代わり、通常料金と比べて最大 50% の割引で利用できます。こちらは、アプリケーションの変更が不要で、購入方法の変更だけでコスト最適化が出来る特徴があります。状況に合わせて、Fargate Spot と Savings Plans を使い分けするといいと思います。

image-20220502161402865.png

Fargate Spot の詳細な説明は、以下の参考リソースも合わせてご確認ください。

参考リソース

今回の記事では、Fargate Spot を利用するときの「中断」について、考慮しなければならない点を整理していきます。また、考慮点と合わせて、設定方法も紹介していきます。

Fargate Spot の中断で考慮するべき内容

Fargate Spot は前述のとおり、キャパシティが確保できなくなったときに中断されます。24時間365日、いつでも中断される可能性があるので、それに備えておく必要があります。中断に備えて、次の点を考慮しておくと良いでしょう。

  • アプリケーションが中断 (SIGTERM) を受け取って、アプリケーションの停止やログの出力をおこなう
  • 何秒前に中断通知を受け取るか、タスク定義で stopTimeout の指定を行う
  • 中断するタスクを、ALB からデタッチする サービスアップデートにより不要となりました
  • ALB の Deregistration Delay を2分未満にする

それぞれの内容についてみていきます。

アプリケーションが中断 (SIGTERM) を受けとる

まず、Fargate Spot の中断が発生した時に何が起こるか説明します。AWS Blog から引用した次の画像を基に説明します。

引用 : https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

image-20220502164213080.png

Fargate Spot で空きキャパシティが確保できなくなった時に、AWS はアプリケーションに通知を送ります。タスク定義のパラメータの中にあるstopTimeout を指定することで、どれくらい前に通知をしてくれるか変更できます。stopTimeout は最大 120 秒なので、中断まで2分の猶予があると言えます。

どのように通知されるかというと、コンテナのエントリプロセス (通常は PID 1) に SIGTERM シグナルが送られます。SIGTERM を受け取ったアプリケーションは、猶予時間の中で安全にアプリケーションを終了出来ます。中断される前の処理としては、次の点を考慮すると良いと思います。

  • アプリケーションの安全な停止 (新規受付停止、途中の処理の完了)
  • バッファしているログの出力・退避
  • 長い処理の中断や、チェックポイントの書き出し

SIGTERM が送られてきてから、stopTimeout で指定した秒数が経過すると SIGKILL が送られます。これによって、アプリケーションが中断される形です。なお、SIGTERM のシグナルを無視していても、SIGKILL で強制終了されます。

アプリケーション側でどのように SIGTERM を受け取れるのか、サンプルアプリケーションのコードが AWS Blog に書かれています。このあたりを確認してみると良いでしょう。

AWS Blog : https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

Go 言語の例を AWS Blog から取り上げてみると、こんな感じです。SIGTERM を受け取ったあとのロジックをプログラム内で組み立てられます。

package main

import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)

func main() {

    sigs := make(chan os.Signal, 1)
    done := make(chan bool, 1)
    //registers the channel
    signal.Notify(sigs, syscall.SIGTERM)

    go func() {
        sig := <-sigs
        fmt.Println("Caught SIGTERM, shutting down")
        // Finish any outstanding requests, then...
        done <- true
    }()

    fmt.Println("Starting application")
    // Main logic goes here
    <-done
    fmt.Println("exiting")
}

また、コンテナイメージのエントリーポイントをどのように構成しているのかも重要なポイントです。コンテナの ENTRYPOINT/bin/sh -c my-app に設定している例を見ていきます。この例では、以下の理由から my-app は正常にシャットダウンしません。

  • sh がエントリプロセスとして実行され、さらに my-app プロセスを生成します。これは sh の子プロセスとして実行されます。
  • sh は、コンテナが停止したときに SIGTERM シグナルを受信しますが、my-app に渡されず、このシグナルに対してアクションを実行するように設定されていません。
  • その後コンテナが SIGKILL シグナルを受信すると、sh とすべての子プロセスが直ちに終了します。

このあたりの対処方法が AWS Blog に記載されています。合わせて参照してください。
URL : https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

stopTimeout を 120秒にする

前述のとおり、Fargate Spot で中断する際に、事前に通知されます。どれくらい前に通知されるのかという猶予時間を、タスク定義の stopTimeout で指定できます。デフォルトは空白となっており、30秒になります。最大 120 秒まで伸ばせるので、基本的には伸ばすのが良いと思います。

image-20220502171257970.png

Target Group から中断する Task をデタッチする (不要)

Fargate Spot が中断する際に、ELB のデタッチをする必要がありましたが、2023 年 2 月 13 日サービスアップデートにより不要になりました。Fargate Spot で SIGTERM が送信される前に、ELB の登録解除がされるようになりました。サービスアップデートは以下の URL をご確認ください。
https://aws.amazon.com/jp/about-aws/whats-new/2023/02/amazon-elastic-container-service-accuracy-service-load-balancing/


Fargate Spot を利用している場合、中断通知が来たときに、ALB に紐づく Target Group から Task をデタッチするのをお勧めします。以下の AWS Blog に次の記載があります。

引用 : https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

スポットインスタンスの自動ドレイン機能は EC2 スポットインスタンスで機能しますが、FARGATE_SPOT として実行されるタスクでは、ロードバランサーのターゲットグループから登録解除されてからタスクが STOPPED 状態に移行するという保証はありません。望ましくないエラーを回避するために、必要な API を呼び出して、ロードバランサーのターゲットグループからタスクを登録解除することをお勧めします。これを処理する方法はいくつかあります。

  1. SIGTERM ハンドラーの中で、DeregisterTargets API を呼び出して、ターゲットグループからタスクを登録解除します。この方法では、AWS SDK をアプリケーションに組み込む必要があります。もしくは、ロードバランサーからタスクを登録解除するロジックを含むサイドカーコンテナを実行することもできます。
  2. タスク状態変更イベントでトリガーされる Lambda 関数を実装します。GitHub の例を参考にできます。イベントの stopCodeTerminationNotice のときに、Lambda 関数でロードバランサーからタスクを登録解除するようにします。

EC2 のスポットインスタンスの場合は、自動的に ALB からデタッチしてくれる機能がありますが、Fargate Spot の場合はこの機能が実行されないことがあるので、別途実行する必要があります。

GitHub の aws-samples で公開されているものを利用すると、簡単に環境にデプロイできます。

URL : https://github.com/aws-samples/ecs-fargate-drain-function

このデプロイ方法は後ほど紹介していきます。

Target Group の Deregistration Delay を2分未満にする

Target Group から Task をデタッチする際の、Deregistration Delay を考慮する必要があります。Deregistration Delay は、Target Group から安全にデタッチするための機能です。Target Group からデタッチするときに、その Task は一定時間 Draining の状態になります。Draining 状態のタスクは、新規リクエストを停止する一方、既存のコネクションを維持しつつ閉じられることを待機する機能です。いきなりデタッチすると途中のリクエストが破棄されてしまうので、安全に待機するための機能ですね。

この Deregistration Delay がデフォルトだと 5分間となっています。Fargate Spot の stopTimeout の最大は2分なので、デフォルトのままだと先に Task が終了してしまう形になります。Deregistration Delay を2分未満の値に設定するのがおすすめです。

考慮点を設定してみる

上記の考慮点を気にしながら、実際に ECS と Fargate Spot でコンテナを動かしてみましょう!

Go言語で SIGTERM を受け取るプログラムを作成

まず、Fargate Spot 上で動かすプログラムを準備します。今回は、Go 言語のプログラムにします。AWS Blog のサンプルコートを参考にしました。

URL : https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

ポイントは次の通りです。

  • Go 言語で Web サーバーを構成
  • SIGTERM を受け付けて、ログにその旨を出力する
package main

import (
	"fmt"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
)

func handler(w http.ResponseWriter, r *http.Request) {
	// 好きな文字を入れる
	fmt.Fprintf(w, "こんにちは、Fargate Spot!\n")
	fmt.Fprintf(w, "\n")

	println("INFO : HTTP アクセスを受け付けました")

}

func main() {
	sigs := make(chan os.Signal, 1)
	done := make(chan bool, 1)
	// SIGTERM を処理するためにチャンネルを登録
	signal.Notify(sigs, syscall.SIGTERM)

	go func() {
		// SIGTERM を受け取ったときに行う処理
		sig := <-sigs
		fmt.Println("Caught SIGTERM, shutting down")
		// インスタンスが停止する前に行うべき処理をする。この例では標準出力に出力するのみ。
		println("SIGTERM を受け付けました : " + sig.String())
		// os.Exit(0)
		done <- true
	}()

	http.HandleFunc("/", handler) // ハンドラを登録してウェブページを表示させる
	http.ListenAndServe(":8080", nil)
}

Go のプログラムをビルドします。

go build -o app

ECR に Docker Image を Push

作成したプログラムを Docker Image 化して、ECR に Push します

FROM public.ecr.aws/docker/library/golang:1.16

RUN mkdir /workdir
COPY app /workdir

EXPOSE 8080
CMD ["/workdir/app"]

Docker Image をビルドします

docker image build -t xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/fargate-spot-testapp:0.0.2 .

ECR にプッシュします

aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/fargate-spot-testapp:0.0.2

Tast Definition 作成

Fargate Spot の通知をどれくらい前にしてくれるのか、stopTimeout の設定をしていきます。今回の手順では、既に存在する Task Definition を更新する手順ですが、新規作成でも基本的な画面構成は同じです。(New ECS Experience の画面では出てこないので、一時的に古いページを使います)

Create new revision を選択します

image-20220502175812289.png

対象のコンテナを選択します

image-20220502175934787.png

Stop Timeout を 120 にして、Update します

image-20220502180045490.png

Create を押します

image-20220502180119782.png

Fargate Spot で ECS Service の作成

新たに作成した Task Definition を使って、Service を作成します。

image-20220501195309402.png

Capacity Provider strategy から FARGATE_SPOT を選択します。これで、Fargate Spot が使われるようになります。

image-20220501195522669.png

Task Definition を利用

image-20220501195559085.png

新たに ALB の作成

image-20220501195700486.png

Security Group などのネットワーキングを指定

image-20220501195849070.png

Service が作成されました

image-20220501201812355.png

対象の Task の詳細画面を見ると、FARGATE_SPOT と書かれており、正常に設定できていることがわかります。

image-20220502181249849.png

Target Group の Deregistration delay の設定変更

考慮点で書いた通り、Target Group から Task をデタッチするときに、安全に取り外すための待機時間が設定されています。これをデフォルトの 300 秒から、120秒未満に設定するのが良いです。今回は、115 秒で設定してみます。

ECS Service に紐づいている Target Group を Edit します。

image-20220501202029255.png

Deregistration Delay を変更して Save を押します

image-20220501202144769.png

更新されました

image-20220501202216919.png

中断通知を受け取って、ALB からデタッチする Lambda Function を作成 (不要)

サービスアップデートにより不要となりました。


考慮点で書いた通り、Fargate Spot の中断通知をトリガーに ALB からデタッチする処理が必要です。今回の記事では、簡単に設定できる、EventBridge と Lambda の組み合わせで行います。aws-samples として GitHub に公開されている内容を基本的にはそのまま利用できるため、比較的簡単に作成できます。

以下の URL で公開されています

URL : https://github.com/aws-samples/ecs-fargate-drain-function

上記の Repository を clone します

git clone https://github.com/aws-samples/ecs-fargate-drain-function.git

clone してきたディレクトリに移動します

cd ecs-fargate-drain-function/

clone してきたディレクトリに Dockerfile があるため、まずはこれを build します

docker build -t aws-samples/aws-lambda-deregister-targets-fargate-spot .

Makefile も一緒に用意されているので、make compile に定義されている内容を実行します。go build などを行っているようです。

make compile

Makefile で定義されている内容を呼びだして、terraform plan を実行します。

  • 新たにデプロイするリソースを表示
  • 引数に、対象のリージョンや、アクセスキーを指定
make awsDefaultRegion=ap-northeast-1 awsAccessKey=xxxxx awsSecretKey=yyyy plan

新たにデプロイされるリソース群が表示されます。いくつか大事なものをピックアップします。

  • EventBridge Rule
  • Lambda Function
  • SQS DeadLetter Queue
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_cloudwatch_event_rule.fargate-spot-rule will be created
  + resource "aws_cloudwatch_event_rule" "fargate-spot-rule" {
      + arn            = (known after apply)
      + description    = "Capture Fargate Spot tasks that are going to be shutdown."
      + event_bus_name = "default"
      + event_pattern  = jsonencode(
            {
              + detail      = {
                  + clusterArn = [
                      + "*",
                    ]
                }
              + detail-type = [
                  + "ECS Task State Change",
                ]
              + source      = [
                  + "aws.ecs",
                ]
            }
        )
      + id             = (known after apply)
      + is_enabled     = true
      + name           = "tf-deregister-targets-fargate-spot-rule"
      + name_prefix    = (known after apply)
      + tags_all       = (known after apply)
    }

  # aws_cloudwatch_event_target.rule_target_lambda_deregister will be created
  + resource "aws_cloudwatch_event_target" "rule_target_lambda_deregister" {
      + arn            = (known after apply)
      + event_bus_name = "default"
      + id             = (known after apply)
      + rule           = "tf-deregister-targets-fargate-spot-rule"
      + target_id      = "aws-lambda-deregister-target-go"
    }

  # aws_iam_role.iam_for_lambda will be created
  + resource "aws_iam_role" "iam_for_lambda" {
      + arn                   = (known after apply)
      + assume_role_policy    = jsonencode(
            {
              + Statement = [
                  + {
                      + Action    = [
                          + "sts:AssumeRole",
                        ]
                      + Effect    = "Allow"
                      + Principal = {
                          + Service = "lambda.amazonaws.com"
                        }
                    },
                ]
              + Version   = "2012-10-17"
            }
        )
      + create_date           = (known after apply)
      + force_detach_policies = false
      + id                    = (known after apply)
      + managed_policy_arns   = (known after apply)
      + max_session_duration  = 3600
      + name                  = "tf-role-deregister-target-fargate-spot"
      + name_prefix           = (known after apply)
      + path                  = "/"
      + tags_all              = (known after apply)
      + unique_id             = (known after apply)

      + inline_policy {
          + name   = (known after apply)
          + policy = (known after apply)
        }
    }

  # aws_iam_role_policy.deregister_policy will be created
  + resource "aws_iam_role_policy" "deregister_policy" {
      + id     = (known after apply)
      + name   = "tf-policy-deregister-target-fargate-spot"
      + policy = (known after apply)
      + role   = (known after apply)
    }

  # aws_lambda_function.lambda_deregister_targets_fargate_spot will be created
  + resource "aws_lambda_function" "lambda_deregister_targets_fargate_spot" {
      + architectures                  = (known after apply)
      + arn                            = (known after apply)
      + filename                       = "build/aws-lambda-deregister-target-go.zip"
      + function_name                  = "tf_deregister_targets_fargate_spot"
      + handler                        = "aws-lambda-deregister-target-go"
      + id                             = (known after apply)
      + invoke_arn                     = (known after apply)
      + last_modified                  = (known after apply)
      + memory_size                    = 128
      + package_type                   = "Zip"
      + publish                        = false
      + qualified_arn                  = (known after apply)
      + reserved_concurrent_executions = -1
      + role                           = (known after apply)
      + runtime                        = "go1.x"
      + signing_job_arn                = (known after apply)
      + signing_profile_version_arn    = (known after apply)
      + source_code_hash               = "3Tsbu8NGlvr09uXoizEwof39r2atqL6aP20b56IkXF4="
      + source_code_size               = (known after apply)
      + tags_all                       = (known after apply)
      + timeout                        = 10
      + version                        = (known after apply)

      + dead_letter_config {
          + target_arn = (known after apply)
        }

      + ephemeral_storage {
          + size = (known after apply)
        }

      + tracing_config {
          + mode = (known after apply)
        }
    }

  # aws_lambda_permission.allow_cloudwatch_to_call_deregister_lambda will be created
  + resource "aws_lambda_permission" "allow_cloudwatch_to_call_deregister_lambda" {
      + action        = "lambda:InvokeFunction"
      + function_name = "tf_deregister_targets_fargate_spot"
      + id            = (known after apply)
      + principal     = "events.amazonaws.com"
      + source_arn    = (known after apply)
      + statement_id  = "AllowExecutionFromCloudWatch"
    }

  # aws_sqs_queue.deadletter_queue_for_deregister_lambda will be created
  + resource "aws_sqs_queue" "deadletter_queue_for_deregister_lambda" {
      + arn                               = (known after apply)
      + content_based_deduplication       = false
      + deduplication_scope               = (known after apply)
      + delay_seconds                     = 0
      + fifo_queue                        = false
      + fifo_throughput_limit             = (known after apply)
      + id                                = (known after apply)
      + kms_data_key_reuse_period_seconds = (known after apply)
      + max_message_size                  = 262144
      + message_retention_seconds         = 1209600
      + name                              = "tf-deadelteer-queue-failed-deregister"
      + name_prefix                       = (known after apply)
      + policy                            = (known after apply)
      + receive_wait_time_seconds         = 10
      + tags_all                          = (known after apply)
      + url                               = (known after apply)
      + visibility_timeout_seconds        = 30
    }

実際にデプロイを行います。AWS 上にリソースが生成されます

make awsDefaultRegion=ap-northeast-1 awsAccessKey=xxxx awsSecretKey=yyy apply

自動生成されたリソース群をいくつか確認していきましょう。

EventBridge

Rule

  • 全てのクラスターで、ECS Task に何かしらの State 変化をトリガーにしている

image-20220501224104497.png

全ての ECS クラスター上で、Task の State が変化したことトリガーにして、Lambda Function を起動

image-20220501224140239.png

Lambda

Lambda Function が生成されている

image-20220501224337645.png

この Function の中身は、こちらのプログラムとなっている

  • Lambda Function が受け取った Event を、CloudWatch Logs に出力
  • Event の中に、TerminationNotice が含まれていると処理を行う。含まれていないと無視する。
  • Service 名と TargetGroup 名を取得して、ALB から登録を解除する

動作確認

EventBridge のルール設定を見ると、Fargate Spot の中断通知に限らず、手動で ECS Task を停止しても、Lambda Function が起動することが期待できます。

ただ、自分の環境だと、Terraform で Deploy しただけだと正常に動作しませんでした。そこで、手動で新たに EventBridge の Rule を設定すると正常に動作しました。

Create rule

image-20220501230716798.png

Next

image-20220501230805983.png

Next

image-20220501230853105.png

Lambda Function を指定

image-20220501230956308.png

Dead Letter Queue も Terraform をつかってデプロイされているので、指定

image-20220501231027740.png

Create Rule を押します

image-20220501231040664.png

その後、動作確認として ECS Task を手動停止します

image-20220501231135137.png

Lambda の実行ログが CloudWatch Logs に出力されます。Lambda が受け取った Event を JSON 形式で出してくれています。

image-20220501231419558.png

付録 : Spot の中断通知をメールで受け取る

ここまで設定しましたが、Fargate Spot の中断がなかなか起きないので、実際の動作確認がまで時間が掛かります (起きないとはいっても、いつでも起きる可能性があるので、それに備えておくのは重要です)
Fargate Spot の中断が発生したときに、すぐにログを確認するために通知設定をしておきましょう。次の URL を参考にしています。

参考 : https://aws.amazon.com/jp/premiumsupport/knowledge-center/fargate-spot-termination-notice/

EventBridge で Create Rule を押します。

image-20220501231538296.png

適当に名前を入れて Next を押します

image-20220501231614946.png

全てのクラスタを対象に、Fargate Spot の中断通知 TerminationNotice が発生したときを条件にします。

{
  "source": [
    "aws.ecs"
  ],
  "detail-type": [
    "ECS Task State Change"
  ],
  "detail": {
    "stopCode": [
      "TerminationNotice"
    ]
  }
}

image-20220501231730251.png

通知先の SNS Topic を選びます

image-20220501231806878.png

Create rule を押します

image-20220501231823248.png

Fargate Spot の発生をまちましょう。

参考URL

ECS のアプリケーションを正常にシャットダウンする方法
https://aws.amazon.com/jp/blogs/news/graceful-shutdowns-with-ecs/

202109 AWS Black Belt Online Seminar Amazon Elastic Container Service − EC2 スポットインスタンス / Fargate Spot ことはじめ
https://www.slideshare.net/AmazonWebServicesJapan/202109-aws-black-belt-online-seminar-amazon-elastic-container-service-ec-fargate-spot

ecs-fargate-drain-function
https://github.com/aws-samples/ecs-fargate-drain-function

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

AWS Fargate Spot タスクで Spot 終了通知を処理するにはどうすればよいですか?
https://aws.amazon.com/jp/premiumsupport/knowledge-center/fargate-spot-termination-notice/

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15