Fargate Spotを本番運用するための監視の実践

SREチームの橋本です。SRE連載の3月号となります。

Amazon ECSのコスト最適化においてはFargate Spotが有効な手段となりますが、いつ中断されるか分からない性質上、その監視も併せて実施していく必要があります。今回はそのFargate Spotを本番環境で運用しているプロジェクトにおける取り組みを紹介します。

背景

Fargate (Amazon ECS on AWS Fargate) を用いると負荷に合わせた容易なスケーリングが可能になる一方、このときCPU使用率の安全マージンや予測のブレなどにより、リソースがやや過剰になってしまうこともあります。

Fargate Spotの代表的なユースケースと言えばユーザーに露出しない開発環境ではないかと思いますが、このような場合にコストを考えると、タスクの中断をある程度許容しての本番環境でのFargate Spot運用も可能な選択肢となってくるでしょう。

そこでリスク管理の手掛かりとして重要になってくるのが各種メトリクスであり、特にどのくらいSpotのタスクが動いているのか、中断率がどのくらいなのかを見つつ調整していく必要があります。 今回Fargate Spotを導入しSpotの比率を考える際にも、これらのメトリクスの情報が不可欠でした。

On-demand/Spotのタスク数の監視

使用状況メトリクスとしてアカウント全体でのSpot使用は確認できますが、負荷の掛かり方の違いもありECSサービスごとの使用量を見たいという状況でした。

私の参加しているプロジェクトで監視はMackerelを用いています。MackerelではAWSインテグレーションによりメトリックの自動取得ができますが、そもそもAWSにないメトリックでは仕方がありません。 ただ当該プロジェクトではmaprobeが稼働しており、任意のコマンドで取得したメトリックをMackerelに送信することができたため、今回もこれを利用しました。

例えば以下のようなシェルスクリプトにより、On-demandとSpotのタスク数を集計することができます。

#!/bin/sh

# fargate_capacity
#
# ECSサービスごとのFargateキャパシティープロバイダーの利用状況を計測する。
# 出力例:
# ecs.capacity.FARGATE.app    7   1699955130
# ecs.capacity.FARGATE_SPOT.app   1   1699955130

metric_prefix=ecs.capacity.
timestamp=$(date +%s)
cluster=""
services=""

while getopts c:s: OPT
do
    case $OPT in
        c)
            cluster=$OPTARG
            ;;
        s)
            services=$OPTARG
            ;;
    esac
done

tasks=$(aws ecs list-tasks --cluster $cluster | jq -r '.taskArns[]')
service_capacities=$(aws ecs describe-services --cluster $cluster --services $services | jq -r '.services[] | select(.capacityProviderStrategy) | .capacityProviderStrategy[].capacityProvider + "." + .serviceName')

data=""

for capacity in $service_capacities
do
    data="$data\n$capacity 0"
done

service_pattern=$(echo $services | sed -e 's/ /|/g')
task_capacities=$(aws ecs describe-tasks --cluster $cluster --tasks $tasks | jq -r ".tasks[] | select(.lastStatus == \"RUNNING\" and (.group | match(\"$service_pattern\"))) | ((.capacityProviderName // .launchType) + \".\" + (.group | split(\":\")[1]))")

for capacity in $task_capacities
do
    data="$data\n$capacity 1"
done

echo $data \
    | awk '{ arr[$1] += $2 } END { for (i in arr) { if (i != "") { print i, arr[i] } } }' \
    | awk "{ print \"$metric_prefix\" \$1 \"\t\" \$2 \"\t\" $timestamp }"

maprobeに対しては以下のように設定すると、各ECSクラスタ(Mackerel上ではECSロールが付いたホストとして扱われる)に対して、上記のスクリプトを起動しカスタムメトリックを投稿してくれます。

probes:
  - service: production
    role: ECS
    command:
      command: 'fargate_capacity -c {{ index .Host.Meta.Cloud.MetaData "cluster-name" }} -s "foo bar"'

Mackerelの実際の画面としては下図のようなグラフとなります。

On-demand/Spotのタスク数グラフ

Fargate Spotの中断の監視

EC2スポットインスタンスであれば中断率が公表されていますが、Fargate Spotにはそのような情報がないようです。よって運用に当たっては自力で中断リスクを管理する必要があります。

またFargate Spotのタスク中断は公式情報によると、流れとしては通常のタスク停止と同様にSIGTERMが送られてstopTimeoutだけ待つ、となっているようです。しかし実際にはFargate Spotの中断だと正常終了しない……というケースも存在するでしょう。こうした時には中断時の挙動を調べる必要があります。

当該プロジェクトではECSタスクの異常終了イベントをCloudWatch Logsに保存しており、これについては例えばClassmethod様のAmazon ECS タスクの停止理由 (エラー内容) を CloudWatch Logs に保存する方法とその分析をしてみた | DevelopersIOをご覧頂ければと思いますが、EventBridgeからCloudWatch Logsへ直接渡して記録できるので簡単に実現できます。

Fargate Spotの中断はstoppedReasonがYour Spot Task was interrupted.となるため、Logs Insightsを用いると以下のようなクエリで抽出することができます。

fields time, detail.group
    | filter detail.stoppedReason like 'Your Spot Task was interrupted'
    | sort @timestamp desc

下図のようにグラフも表示され、簡単に分析することができます。

CloudWatch Logs InsightsでSpot中断を分析する

なおこうして中断率を見ると、概算で1時間あたり1%弱の中断率が計測されました。上記のEC2スポットインスタンスでは1ヶ月で5%未満といったデータが多いので、Fargate Spotではかなり性質が異なるのかもしれません。

またLogs Insightsの活用については、他にも「CloudWatch Logs Insights クエリを定期的に実行して結果をS3に置く」など紹介していますので併せてご覧ください。

まとめ

今回は自社OSSやAWSの各種サービスを活用したFargate Spotの監視について紹介しました。

Fargate Spotはその大きな割引率によってコスト最低化に当たり大きな魅力を持ち、また中断のリスクもコンテナアプリケーションとしてお行儀が良ければ、つまりステートレスであれば御しやすいものと言えます。 適切な監視と共にECSのキャパシティーの一部として活用すれば、スケーリングに更なる自由度をもたらすことができるのではないかと思います。

カヤックでは、サービスの選択肢を増やす監視に興味があるエンジニアも募集しています。

Ruby 3.3でのアップデートも要チェック!まちのコインでYJITを有効化したはなし

SREチームの長田です。 今回はRubyのJITコンパイラであるYJITのはなしです。

カヤックが開発・運用している地域通貨サービス「まちのコイン」は、Ruby on Railsを使用しています。 このまちのコインにてYJITを有効化し、その結果どのような影響があったのかを紹介します。

coin.machino.co

YJITとは

YJITは RubyのJITコンパイラです。 Ruby 3.1までは実験的な機能という位置づけでしたが、 Ruby 3.2から実用段階となりました。

Basic Block Versioning (BBV)を採用した遅延コンパイルにより、コード実行の高速化を図っているようです。 YJITそのものの話題については、今回は割愛させていただきます。

まちのコインの状況

まちのコインでは昨年6月末頃に Ruby 3.1.x から Ruby 3.2.x にアップデートを行いました。 その時点でYJITを利用できるようにはなっていたわけですが、当時はまだ「有効にしたらどうなるかねー」という会話があった程度でした。

その後他社の本番環境でのYJIT有効化事例が見られるようになり、 そろそろうちでも有効にしてみるかーとようやく動き始めたのが昨年の12月頃でした。

YJIT有効化・・・の前に

まずは計測できる状態にしなければ効果が測れないので、YJITのstatsをメトリクスとして取得するようにしました。

YJITのstatsは RubyVM::YJIT.runtime_stats で取得できます。 アプリケーションがリクエストを受けるたびにこれをログ出力しています。 必要に応じて一定の確率でログ出力するなどしてログの量を抑制すると良いでしょう。

Rails.logger.info(
    {
        app_mode: ENV.fetch('APP_MODE', nil),
        **RubyVM::YJIT.runtime_stats,
    },
)

app_mode はアプリケーションの動作モードです。 まちのコインではひとつのRailsアプリがいくつかのモードで動作しているので、 これを区別するためにログに含めています。

RailsアプリはECS上で動作しているので、出力したログは Amazon CloudWatch Logs に集約されます。 これに対して Amazon CloudWatch Metric Filter を設定し、でメトリクスとして集計・投稿しています。

以下に Terraform を使った設定例を示します。 今回は継続的に観察するメトリクスとして code_region_sizeyjit_alloc_size を選びました。

resource "aws_cloudwatch_log_metric_filter" "app-ruby-yjit" {
  for_each = toset([
    "code_region_size",
    "yjit_alloc_size",
  ])

  name           = "ruby-yjit-${each.key}"
  log_group_name = aws_cloudwatch_log_group.ecs-task["app"].name
  pattern        = "{$.${each.key} = *}"

  metric_transformation {
    namespace = "App/RubyYJIT"
    name      = each.key
    value     = "$.${each.key}"
    unit      = "Bytes"

    dimensions = {
      app_mode = "$.app_mode"
    }
  }
}

他のメトリクスについても必要があれば for_each で処理するリストに追加することになります。 CloudWatch に送信していないメトリクスを一時的に眺めたい場合は、CloudWatch Logs Insights でログを集計しています。

これで計測の準備ができたので、次はいよいよ有効化です。

YJITの有効化

まずは有効にして様子を見てみないとなんともならんということで、 RUBY_YJIT_ENABLE=1 を設定してデプロイしてみたところ、 APIのレスポンスタイムが2倍程度に悪化しました

悪化の原因はYJIT有効化によるメモリ使用量の増加でした。 まちのコインのRailsアプリはHTTPサーバーとしてunicornを使用しています *1。 YJIT有効化後にunicornのworkerが使用するメモリ量が増え、 unicorn worker killer に頻繁にkillされるようになり、 workerプロセスの再起動が頻発。 結果としてAPIレスポンスタイムが悪化した、というものでした。

対応として、unicorn worker killerの閾値調整と、ECS Taskに割り当てるメモリ量を増やしました。 元の閾値が必要以上に小さかったということも影響していたようです。 対応の結果、APIレスポンスタイムが平均 9%程度短縮 されました。 パラメータ変更のみで10%近く短縮できたのは大きいですね。

APIレスポンスタイムのグラフ

また、CPU使用率にも若干の減少が見られました。

CPU使用率のグラフ

Ruby 3.3.0 へのアップデート

昨年12月にリリースされた Ruby 3.3.0。 リリースノート によると YJITのパフォーマンスおよびメモリ使用量が大幅に改善したとのことだったので、早速試してみることにしました。

Ruby 3.2系から3.3系への変更は、インターフェイスの変更が含まれていないこともあり、ほぼ工数ゼロで移行完了しました。 しかし、 APIレスポンスタイムの短縮という観点では、ほとんど効果は見られませんでした

unircorn の after_fork でYJITを有効化

Ruby 3.3 からYJITに追加された機能として、ランタイムでの有効化があります。

  • RubyVM::YJIT.enable を追加し、実行時にYJITを有効にできるようにしました
    • コマンドライン引数や環境変数を変更せずにYJITを開始できます。Rails 7.2はこの方法を使用して デフォルトでYJITを有効にします。
    • これはまた、アプリケーションの起動が完了した後にのみYJITを有効にするために使用できます。YJITの他のオプションを使用しながら起動時にYJITを無効にしたい場合は、--yjit-disable を使用できます。

Ruby 3.3.0 リリース より

YJITの有効化をアプリケーション起動時ではなく、 unicorn workerのfork後に行うことでメモリ使用量を抑えられる場合があるとのことでしたので、 config/unicorn.rb に以下のようなコードを追加しました。

after_fork do |_server, _worker|
  if rails_env == 'production'
    RubyVM::YJIT.enable
    Rails.logger.info('YJIT enabled')
  end
end

結果としては、20MB程度だった code_region_size が15MB程度に減少しました (オレンジの線がmax、青の線がaverage)。

code_region_sizeのグラフ

しかし、もともとの code_region_size が少なかったため、 ECS Task全体で見たときのメモリ使用量減少についてはあまり効果がありませんでした。

--yjit-exec-mem-size の調整

--yjit-exec-mem-size はYJITが生成するコード量を制限する設定値です。 YJITのドキュメントには以下のように説明されています ((--yjit-exec-mem-size のデフォルト値は、以降のバージョンで48MiBに変更されるようです。 https://github.com/ruby/ruby/pull/9685))

--yjit-exec-mem-size=N: size of the executable memory block to allocate, in MiB (default 64 MiB)

前述のとおり、code_region_size は15MB程度と小さかったため、調整は行いませんでした。

より小さな値を設定すればその分メモリ量は削減できるはずですが、 例えば --yjit-exec-mem-size=8 として半分に制限したとしても、 現状のECS Taskあたりのunicorn worker数16 * 8MiB = 128MiB 程度しか削減できないことになります。

--yjit-call-threshold の調整

--yjit-call-threshold はYJITがコンパイルするメソッドの呼び出し回数の閾値です。

こちらもYJITのドキュメントを参照すると、以下のように説明されています。

--yjit-call-threshold=N: number of calls after which YJIT begins to compile a function. It defaults to 30, and it's then increased to 120 when the number of ISEQs in the process reaches 40,000.

デフォルト値は30ですが、説明の後半にあるようにISEQs(コンパイルされたバイトシーケンス量)が40,000に達すると 自動で120に引き上げられるようです。 コンパイル量が一定以上になると新たにコンパイルされにくくなるということですね ((YJITのstatsには compiled_iseq_count というISEQsを得られるものがあり、 この値と照らし合わせて、自動引き上げの閾値である40,000を超えているかどうかを確認するとよさそうです。 まちのコインの場合は5500〜6500程度でした))。

こちらもは挙動の確認をしたかったので30→60→120と変化させて観察してみたのですが、 code_region_size に影響が出るほどではありませんでした。 こちらも元の code_region_size が小さいため、観測できるほどの変化がなかったようです。

まとめ

YJITを有効化するだけで平均9%のレスポンスタイム短縮効果が得られたのは、非常にコスパの良いチューニングでした。 他社事例のように10%単位での高速化は見られませんでしたが、それでも十分な結果と言えるでしょう。

YJIT有効化部分を読んで、「そんなにいきなり有効にして大丈夫なの?」と思った方もいるかもしれません。 実際のところ厳密な回帰テストなどは行わず、本番環境適用前の検証はCIの通過と、 クライアントアプリとの疎通確認環境での簡単な動作確認のみでした。

検証を簡略化できた理由として、 「エラーバジェットの残量が十分にあった」こと、 「不具合が発生したとしてもすぐにロールバックできる仕組みがある」こと、 の2点が挙げられます。

「YJITの有効化」で触れたとおり、有効化直後はレスポンスタイムの悪化がありましたが、 エラーバジェットを枯渇させるほどのものではありませんでした。 今回の件は、エラーバジェットを正しく使えた事例としても意義があったと思います。

これからもエラーバジェットをうまく使って、新しい技術や最新のアップデートを取り入れていきたいと考えています。

参考資料


カヤックでは試行錯誤が好きなエンジニアを募集しています! hubspot.kayac.com

*1:最近のRailsでは、デフォルトのHTTPサーバーとしてpumaが使われています。 まちのコインではマルチテナントをデータベースレベルで実現するためにApartmentを使用しており、 これがマルチスレッド方式であるpumaとの相性が悪く、pumaではなくunicornを採用したという歴史があります。 まちのコインのApartmentについては こちらの記事 を御覧ください。