はじめに
これは はてなエンジニア - Qiita Advent Calendar 2024 - Qiita 14日目の記事です。昨日は id:ymse の compose.yamlはマージができるし、YAMLのtagでその挙動をコントロールできる - 風に吹かれても でした。
id:utgwkk です。来週末に韓国に行くことが決まりました。それはさておいて……
先日 (12/8)、ISUCON14が開催されました。ISUCON14に参加したことの記録は以下の記事をご覧ください。
話は変わりますが、みなさまはAWS Step Functionsを使っていますか? 今日はISUCONの感想戦を支えるStep Functionsについてお話しします。
できたもの
いきなりですが以下のステートマシンをご覧ください。どうですか?
このステートマシンは、ISUCONの競技用EC2インスタンスを起動・停止するためのステートマシンです。そんなのAWSコンソールからぽちぽち起動・停止して回ったらよくない?? と思われる方もいるでしょうが、まあそう言わずちょっと読んでいってください。
使い方
EventBridge Scheduler経由で起動する
仕事中はさすがに感想戦をやっている場合ではない (なぜなら仕事があるから)、けど仕事が終わったらすぐに感想戦に取りかかりたい、あとインスタンスの実行費用をケチりたい……そういうときはEventBridge Schedulerでステートマシンの起動をスケジュールしておくと便利です。
夜19時にEC2インスタンスを起動するスケジュールと、朝10時にEC2インスタンスを停止するスケジュールの両方を用意する、というのをやっています。定時を過ぎるとステージング環境が停止するのの逆みたいで面白いですね。
EventBridge Schedulerではスケジュールの終了日時を設定できるので、感想戦用にダッシュボード・ベンチマーカーが公開される期間が終わったらEC2インスタンスを起動するスケジュールを終了させる、という設定を入れることで、インスタンスの実行費用がかさむのを防げます。まあ最終的にはCloudFormationのスタックごと消すのがいいと思いますが……。
EventBridge+SNS経由で通知する
EventBridgeに以下のようなイベントパターンでSNSに通知する設定を用意します。ステートマシンの実行名を test-
から始めた場合は通知しないようにしています。こうすることでテスト実行の通知がSlackに流れまくるのを防げます。
{ "source": ["aws.states"], "detail-type": ["Step Functions Execution Status Change"], "detail": { "status": ["SUCCEEDED", "TIMED_OUT", "FAILED", "ABORTED"], "stateMachineArn": ["ステートマシンのARN"], "name": [{ "anything-but": { "prefix": ["test-"] } }] } }
SNSのサブスクリプションにAWS ChatBotを登録し、Slackチャンネルにステートマシンの実行を通知するようにしています。EventBridge→SNS→ChatBot という感じで、Slack通知をするための登場人物がやや多い気もしますが、こんなもんでしょうか。
AWS ChatBot経由で起動する
ISUCON用のプライベートなSlackチャンネルにステートマシンの実行通知を流しているのですが、そのメッセージから競技用EC2インスタンスを起動・停止できるようにしてあります。このようにしておくことで、チームメンバーがAWSアカウントにログインできなくても、Slackに流れてきたメッセージのボタンを押すだけで感想戦に取りかかれます。
通知メッセージのハンバーガーメニューで開くモーダルからCreate a new custom action buttonを押すことで、任意のAWS CLIのコマンドを実行するボタンを設置できます。ステートマシンを実行するなら stepfunctions start-execution
コマンドを記述して保存すればよいです。
ステートマシンの解説
定義
以下にこのステートマシンの定義を記述します。リトライ処理は省略しています。
{ "StartAt": "DescribeInstances", "States": { "DescribeInstances": { "Type": "Task", "Parameters": { "Filters": [ { "Name": "tag:aws:cloudformation:stack-id", "Values": [ "ISUCON競技用インスタンスのCloudFormationスタックID" ] } ] }, "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances", "ResultSelector": { "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId" }, "Next": "DetermineNextInstanceStatus", }, "DetermineNextInstanceStatus": { "Type": "Choice", "Choices": [ { "Variable": "$$.Execution.Input.DesiredState", "StringEquals": "running", "Next": "StartInstances" }, { "Variable": "$$.Execution.Input.DesiredState", "StringEquals": "stopped", "Next": "StopInstances" } ], "Default": "Nop" }, "Nop": { "Type": "Pass", "End": true, "Result": { "Nop": true } }, "StartInstances": { "Type": "Task", "Parameters": { "InstanceIds.$": "$.InstanceIds" }, "Resource": "arn:aws:states:::aws-sdk:ec2:startInstances", "End": true }, "StopInstances": { "Type": "Task", "Parameters": { "InstanceIds.$": "$.InstanceIds" }, "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances", "End": true } } }
やっていること
やっていることは簡単です。
- DescribeInstances APIを叩き、ISUCON14の競技用EC2インスタンスIDを列挙する
- ステートマシンへの入力に応じて、列挙した競技用EC2インスタンスを起動もしくは停止する
みどころ
ResultSelectorでインスタンスIDの配列を入手する
DescribeInstances APIの実行結果は、ものすごくざっくり書くと*1以下のような形式のJSONになっています。
{ "Reservations": [ { "Instances": [ { "InstanceId": "i-0001", // 省略 } ] }, // 省略 ] }
Reservations
フィールドの配列の各要素のオブジェクトにある Instances
フィールドの配列の各要素のオブジェクトの InstanceId
フィールドの値がインスタンスIDです。これを使ってEC2インスタンスを起動・停止するためのAPIを呼び出します。
ところで、EC2インスタンスを起動・停止するためのAPIにはインスタンスIDを複数渡して一括操作することができます。これを使いたいのでなんとか工夫します。
先に答えから書いておくと、ResultSelectorを使ってDescribeInstances APIが返すJSONからインスタンスIDの配列を得ることができます。$.Reservations[*].Instances[*].InstanceId
というJSONパスを書いている箇所がそれに対応します。
こうして DescribeInstances
stateの出力を以下のようなインスタンスIDの配列 (を持つオブジェクト) にできました。ここまで来たらやるだけですね。
{ "InstanceIds": [ "i-0001", "i-0002", "i-0003" ] }
$$.Execution.Input
でステートマシンそのものへの入力を得る
$$
から始まるJSONパスはcontext objectを指しています。context objectを使うことで、ステートマシンの実行全体にまつわる情報を任意のstateで参照できます。
ステートマシンの入力の DesiredState
フィールドの値に応じてEC2インスタンスを起動するか・停止するか の分岐を書くために使っています。こうするとstateごとに元の入力を引き継ぐことを考えなくて済んで便利ですね。
途中のステートの出力を引き回したいときは変数が使えると思います。
なぜStep Functionsで書くのか
AWSの各種APIを順番に叩くのはLambda関数でもできるのでは? と思われるかもしれません。Amazon States Languageを頑張って覚えるよりは、馴染みのあるプログラミング言語で実装できる方が完成するのは早いと思います。また、実行頻度によってはStep FunctionsよりもLambdaのほうが圧倒的に安くなることもあるでしょう。それでもなぜStep Functionsで書くのか?
面白い
ステートマシンを書くときは、普段プログラムを書くときとはまた別の筋肉を求められます。入出力をうまく加工したり持ち回したりするにはどうするのか、状態遷移数を最小にできるか、など、意外とやりこみ要素があって面白いです。
データを加工するだけのLambda関数を使わない、みたいな縛りを入れるのもいいですね。入出力の加工をLambda関数なしで達成できると脳汁がドバドバ出ます。とはいえ、コストを最適化するにはLambda関数を通すほうが安くなる場合もあるだろうし、Step Functionsだけでは実現できないことも出てくると思うので、要はバランスですね。
言語ランタイムのEoLを気にしなくてよい
プログラミング言語には、処理系のEoLがつきものです。Lambda関数のランタイムだってそうです。長いこと放置して安定稼動していたLambda関数のランタイムもいつかは寿命を迎えます。処理系のバージョンアップに追従するコストを払わなければなりません。
Step Functionsで書いておくと、AWSのAPI呼び出しのためのLambda関数などは不要になります。このLambda関数のランタイムのバージョンが勝手に上がってもいいんだっけ……などと気にする必要はありません。
リトライ処理が簡単に書ける
DescribeInstances APIが内部エラーで失敗したときに、ジッターを入れつつリトライする、という処理をすらすら書けますか? Step Functionsの場合は、state定義に Retry
フィールドを書くだけでよしなにやってくれます。
手札を知る
この節はStep FunctionsやAWSに限らない話題になるのですが、いろいろな道具を組み合わせて何がどこまで・どれくらいの労力で実現できるのか、を知っておくのがいいと思います。Step Functionsが銀の弾丸に見えているうちにいろいろ作ってみて、こんなに簡単に実現できるとか、実はStep Functionsを通す必要がなかったとか、そういう経験を積んでおくのがいいでしょう。
おわりに
とはいえ生のJSONでステートマシンを記述するのはさすがに大変です。ちょっと分岐して直列に進むぐらいならいいけど、分岐が複雑になったらJSONだといずれは認知の限界が来そう。デザインエディタで可視化して作りつつIaCしたくなったらJSONエクスポートするとか?
みなさまはどうやってステートマシンを書いていますか? AWS CDKでステートマシンを定義できるやつとかを活用するともっと見通しよく書けるのかな。
株式会社はてなでは、サーバーレス技術でかっこいいシステムを作りたい方を募集しています。
明日の担当は id:mizdra です。
*1:ちゃんとした定義は DescribeInstances - Amazon Elastic Compute Cloud を参照してください