開発環境のインスタンスが直近どのくらいの時間起動していて、料金がいくらかかっているかを Slack に簡単に通知する Step Functions

記事タイトルとURLをコピーする

こんにちは😸
カスタマーサクセス部の山本です。

概要

この記事では、現在稼働中のEC2インスタンスの直近(*1)での概算利用料金を定期的に計算しSlackに通知する仕組みを解説します。
AWS Step Functions と EventBridge を活用します。
*1 ・・・最後に起動してから現在まで

Step Functions 内では Lambda を使わずに 計算処理を JSONata を使用して行っています。なるべく簡潔な構成にすることを目指しました。

Slack への通知イメージ。

Slack 側の設定 (アプリの作成)

対象の Slack ワークスペースにログインした状態で、以下の URL に遷移します。

Slack API: Applications | Slack

左ペインの OAuth & Permissions を開きます。

User Token Scopeschat:write の権限を付与し、アプリがメッセージを投稿できるようにします。

以下の権限が付与されるようです。

  • chat.delete
  • chat.deleteScheduledMessage
  • chat.postEphemeral
  • chat.postMessage
  • chat.scheduleMessage
  • chat.update

OAuth Tokens の下にあるボタンを押し、アプリをワークスペースにインストールします。

User OAuth Token をコピーします。
(トークンですので、意図しない人が見えるようなところにペーストしないようにしてください。)

Slack アプリなどから、通知先 Slack チャンネルの ID を確認してコピーしておきます。

メモしておくものまとめ

メモしておくもの 説明
User OAuth Token アプリインストール後に表示される
通知先 Slack チャンネルの ID Slack のチャンネルの詳細から確認

EventBridge の 「接続」作成

EventBridge から Slack のチャンネルに投稿できるように、「接続」を作成します。

「認証タイプ」に「API キー」を選択します。「API キー名」は「Authorization」、「値」は「Bear <User OAuth Token>」にします。 Bear のあとに Slack の User OAuth Token を入れてください。

作成後に「接続の ARN 」をメモしてください。

メモしておくものまとめ

メモしておくもの 説明
接続の ARN 「接続」を作成後に表示されるようになる

インスタンスタイプごとの料金をメモ

以下の URL を参考に、使用中のインスタンスタイプの料金をメモしておきます。

変更になる可能性もあるものの、一旦以下の固定値をメモしました。

インスタンスタイプ 料金 補足
t3.micro 0.0104 USD 1 時間あたり
t2.micro 0.0116 USD 1 時間あたり

Pricing API というものもあるので、それを使って自動化しても良いとは思います。
今回は簡単な構成とするために使わず、固定値にします。

Step Functions

このステートマシンは、現在稼働中の全てのEC2インスタンス情報を取得し、各インスタンスの詳細(インスタンスタイプ、稼働時間、概算コスト)をSlackに通知することを目的としています。

{
  "Comment": "A description of my state machine",
  "StartAt": "DescribeInstances",
  "States": {
    "DescribeInstances": {
      "Type": "Task",
      "Arguments": {
        "Filters": [
          {
            "Name": "instance-state-name",
            "Values": [
              "running"
            ]
          }
        ]
      },
      "Output": "{% [$states.result.Reservations[*].Instances[*]] %}",
      "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
      "Next": "Map"
    },
    "Map": {
      "Type": "Map",
      "ItemProcessor": {
        "ProcessorConfig": {
          "Mode": "INLINE"
        },
        "StartAt": "PostInstanceInfoToSlack",
        "States": {
          "PostInstanceInfoToSlack": {
            "Type": "Task",
            "Resource": "arn:aws:states:::http:invoke",
            "Arguments": {
              "ApiEndpoint": "https://slack.com/api/chat.postMessage",
              "Method": "POST",
              "InvocationConfig": {
                "ConnectionArn": "arn:aws:events:ap-northeast-1:123456789012:connection/xxxxxxxx/XXXX-XXXXX-XXXX"
              },
              "RequestBody": {
                "channel": "CXXXXXX",
                "blocks": [
                  {
                    "type": "section",
                    "text": {
                      "type": "mrkdwn",
                      "text": "{% 'EC2 Instance Report: *' & $states.input.Tags[Key='Name'].Value[0] & '* (`' & $states.input.InstanceId & '`)' %}"
                    }
                  },
                  {
                    "type": "divider"
                  },
                  {
                    "type": "section",
                    "fields": [
                      {
                        "type": "mrkdwn",
                        "text": "{% '*Instance Name:*\n' & $states.input.Tags[Key='Name'].Value[0] %}"
                      },
                      {
                        "type": "mrkdwn",
                        "text": "{% '*Instance Type:*\n' & $states.input.InstanceType %}"
                      },
                      {
                        "type": "mrkdwn",
                        "text": "{% '*Running Hours:*\n' & $string($ceil(($toMillis($now()) - $toMillis($states.input.LaunchTime)) / 3600000)) & ' hours' %}"
                      },
                      {
                        "type": "mrkdwn",
                        "text": "{% '*Estimated Cost:*\n$' & $string(($states.input.InstanceType = 't3.micro' ? 0.0104 : $states.input.InstanceType = 't2.micro' ? 0.0116) * $ceil(($toMillis($now()) - $toMillis($states.input.LaunchTime)) / 3600000)) %}"
                      }
                    ]
                  }
                ]
              }
            },
            "Retry": [
              {
                "ErrorEquals": [
                  "States.ALL"
                ],
                "IntervalSeconds": 1,
                "MaxAttempts": 3,
                "BackoffRate": 2,
                "JitterStrategy": "FULL"
              }
            ],
            "End": true
          }
        }
      },
      "End": true
    }
  },
  "QueryLanguage": "JSONata"
}

コード内の以下を変更ください。

変更する箇所 入れる値
ConnectionArn メモした「接続の ARN」
"channel": "CXXXXXXXX" メモした「通知先 Slack チャンネルの ID」
($states.input.InstanceType = 't3.micro' ? 0.0104 : $states.input.InstanceType = 't2.micro' ? 0.0116) メモした「インスタンスタイプ」とその「料金」を入れるように JSONata 式を変更してください。

Step Functions の全体構成

このステートマシンは、2つの主要なステップで構成されています。

  1. DescribeInstances: AWS APIを呼び出して、実行中の全EC2インスタンスのリストを取得します。
  2. Map: 取得した各インスタンスの情報を使って、Slackに通知メッセージを並列で送信します。

また、"QueryLanguage": "JSONata" を指定しており、ステートマシン全体でデータの抽出や変換に JSONata を使用しています。これにより、複雑なデータ操作を簡潔に記述できます。

1. DescribeInstances ステップ

このステップは、ステートマシンの開始点 (StartAt) です。AWS SDK統合を利用してEC2インスタンスの情報を取得します。

  • Type": "Task": 特定の処理を実行する基本単位のステップです。
  • Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances": Step FunctionsのAWS SDK統合機能を使って、EC2の describeInstances APIを直接呼び出します。
  • Arguments: APIに渡す引数を指定します。
    • Filters: instance-state-namerunning であるインスタンスのみを抽出するようにフィルタリングしています。
  • Output": "{% [$states.result.Reservations[*].Instances[*]] %}": JSONataを使用して、describeInstances のAPIレスポンスからインスタンス情報の配列のみを抽出します。$states.result はAPIの生の出力結果を指します。この整形により、次のMapステップで扱いやすい形式のデータ(インスタンスオブジェクトの配列)を渡します。
  • Next": "Map": このステップが完了したら、次にMapステップへ遷移します。

2. Map ステップ

このステップは、前のステップから受け取ったインスタンスの配列を処理します。配列の各要素(各インスタンス)に対して、内部で定義された一連の処理 (ItemProcessor) を並列実行します。

  • Type": "Map": 配列の各項目に対して同じ処理を繰り返すためのフローステートです。
  • ItemProcessor: 配列の1つの要素に対して実行される処理内容を定義します。
    • ProcessorConfig: { "Mode": "INLINE" }: Mapステップの処理モードをインラインに設定します。これにより、子ワークフロー(各インスタンスの処理)の実行履歴が、親であるステートマシンの実行履歴内に直接表示され、デバッグが容易になります。
    • StartAt": "PostInstanceInfoToSlack": ItemProcessor内の最初のステップを指定します。

PostInstanceInfoToSlack ステップ (ItemProcessor内)

このステップは、各EC2インスタンスの情報を取得し、Slack APIを呼び出して通知メッセージを投稿します。

  • Type": "Task": 処理を実行するステップです。
  • Resource": "arn:aws:states:::http:invoke": Step FunctionsのHTTPSエンドポイント呼び出し機能を利用します。これにより、外部のAPI(今回はSlack API)と連携します。
  • Arguments: API呼び出しに必要なパラメータを指定します。
    • ApiEndpoint: 呼び出すAPIのエンドポイント (https://slack.com/api/chat.postMessage) を指定します。
    • Method": "POST": HTTPメソッドとしてPOSTを使用します。
    • InvocationConfig.ConnectionArn: Slack APIの認証情報(OAuthトークンなど)を安全に保持するEventBridge API接続のARNを指定します。これにより、ステートマシンの定義内に直接認証情報を記述する必要がなくなります。
    • RequestBody: Slack APIに送信するリクエストボディです。SlackのBlock Kit形式でメッセージを構成しています。
      • channel: 通知先のSlackチャンネルIDです。
      • blocks: メッセージのレイアウトを定義します。ここでJSONata式が多用されています。
        • ""{% 'EC2 Instance Report: *' & $states.input.Tags[Key='Name'].Value[0] & '* (' & $states.input.InstanceId & ')' %}": レポートのタイトル。$states.inputMapステップから渡された単一のインスタンス情報を指します。
        • "text": "{% '*Running Hours:*\n' & $string(...) %}": 現在時刻 ($now()) とインスタンスの起動時刻 ($states.input.LaunchTime) の差分から、稼働時間をミリ秒で計算し、時間単位に変換して切り上げています ($ceil(...) / 3600000)。
        • "text": "{% '*Estimated Cost:*\n$' & $string(...) %}": インスタンスタイプ ($states.input.InstanceType) に応じて時間単価を決定し(三項演算子 ? : を使用)、稼働時間を掛けて概算コストを計算しています。
  • Retry: API呼び出しが失敗した場合のリトライ戦略を定義します。
    • ErrorEquals": ["States.ALL"]: すべての種類のエラーをリトライ対象とします。
    • IntervalSeconds": 1: 最初のリトライは1秒後に行います。
    • MaxAttempts": 3: 最大3回までリトライします。
    • BackoffRate": 2: リトライごとに待機時間を2倍にします(1秒→2秒→4秒)。
    • JitterStrategy": "FULL": リトライ間隔にランダムな揺らぎを追加し、複数の処理が同時にリトライすることを防ぎます。
  • End": true: このステップがItemProcessorの最後のステップであることを示します。

Mapステップ自体も "End": true となっており、すべてのインスタンスに対する通知処理が完了すると、このステートマシン全体が成功として終了します。

JSONata 式解説

'*Running Hours:*\n' & $string($ceil(($toMillis($now()) - $toMillis($states.input.LaunchTime)) / 3600000)) & ' hours'

この一行は、3つの部分を & 記号で連結(結合)して、1つの文字列を作っています。

  1. '*Running Hours:*\n'

    • これは表示する文字列の先頭部分です。
    • *Running Hours:* というテキストと、\n(改行)を意味します。アスタリスク * は、Markdownなどの書式で太字として表示されることを意図しています。
  2. $string($ceil(...))

    • これが稼働時間を計算している中心部分です。計算は内側から外側へと進みます。
    • $toMillis($now()) - $toMillis($states.input.LaunchTime):
      • $now()現在時刻を、$states.input.LaunchTime起動時刻をそれぞれ取得し、$toMillis() でミリ秒単位の数値に変換します。
      • その差を計算することで、経過時間をミリ秒で算出します。
    • ... / 3600000:
      • 算出した経過ミリ秒を 3,600,000 (60分 × 60秒 × 1000ミリ秒) で割ることで、時間単位に変換します。この時点では 2.5 のように小数点以下の値になる可能性があります。
    • $ceil(...):
      • 時間単位に変換した値を、$ceil() 関数で小数点以下を切り上げて、最も近い整数にします。例えば 2.1 時間でも 3 時間、0.5 時間でも 1 時間になります。これは、過去のクラウドサービスで主流だった「1時間未満の利用は1時間に切り上げて課金する」という考え方を反映した計算方法です。現在は秒単位の課金ですが、概算ですし計算が複雑になるので一旦はこの形です。
    • $string(...):
      • 最後に、計算結果の数値(例: 3)を文字列(例: "3")に変換します。
  3. ' hours'

    • 計算結果の後ろに追加する単位の文字列です。
実行結果の例

仮にインスタンスが 2時間30分 稼働していたとします。

  1. 経過時間は 2.5 時間です。
  2. $ceil(2.5) によって 3 に切り上げられます。
  3. 最終的に、すべてのパーツが結合され、以下のような文字列が生成されます。
*Running Hours:*
3 hours

'*Estimated Cost:*\n$' & $string(($states.input.InstanceType = 't3.micro' ? 0.0104 : $states.input.InstanceType = 't2.micro' ? 0.0116) * $ceil(($toMillis($now()) - $toMillis($states.input.LaunchTime)) / 3600000))

この式は、大きく分けて「料金の選択」と「稼働時間の計算」を行い、それらを掛け合わせて最終的なコストを算出しています。

  • ($states.input.InstanceType = 't3.micro' ? 0.0104 : $states.input.InstanceType = 't2.micro' ? 0.0116)

    • ? : (三項演算子) を使って、インスタンスの種類に応じた時間単価を決定しています。
    • もし $states.input.InstanceType't3.micro' なら、時間単価として 0.0104 ドルを選択します。
    • そうでなく、もし 't2.micro' なら、時間単価として 0.0116 ドルを選択します。
  • $ceil(($toMillis($now()) - $toMillis($states.input.LaunchTime)) / 3600000)

    • これは先ほどの Running Hours の計算と全く同じです。
    • インスタンスの起動から現在までの経過時間を算出し、時間単位に変換したあと、$ceil()小数点以下を切り上げています
    • これにより、1時間未満の利用でも1時間として計算されます。

最後に、ステップ1で選択した時間単価と、ステップ2で計算した切り上げ後の稼働時間を掛け算し、概算コストを算出します。

$string(...) はその計算結果を文字列に変換し、先頭の '*Estimated Cost:*\n$' と連結して最終的な表示を作成します。

実行結果の例

仮にインスタンスが以下の条件だったとします。

  • インスタンスタイプ: t2.micro
  • 稼働時間: 2時間30分
  1. 料金の選択: t2.micro なので、時間単価は $0.0116 になります。
  2. 稼働時間の計算: 2時間30分 (2.5時間) は $ceil() によって 3 時間に切り上げられます。
  3. 最終計算: $0.0116 * 3 = $0.0348 となります。
  4. 出力: すべてが連結され、以下の文字列が生成されます。
*Estimated Cost:*
$0.0348

この計算方法は、経過時間を常に時間単位で切り上げるため、現在の主流である秒単位の課金体系とは異なります。 そのため、実際の請求額より高く見積もられる点にご注意ください。

EventBridge スケジューラへの登録

作成した Step Functions を定期的に実行する EventBridge ルールを作成すると、Slack への投稿が開始されます。
詳細は割愛します。

参考:Amazon EventBridge スケジューラを使用して Step Functions ステートマシンの実行を開始する - AWS Step Functions

数時間ごとに投稿されるようにしてみました。

まとめ

今回は、AWS Step Functions を中心に、稼働中のEC2インスタンスの情報を取得し、概算料金を計算してSlackに通知する仕組みを構築しました。この構成により、AWSの利用状況をより手軽に、かつプロアクティブに把握することが可能になります。

今回は固定値で料金を計算するなど、簡易的な実装に留めました。さらに実用的にするための改善案をいくつかご紹介します。

  • AWS Pricing APIの活用: 今回はインスタンスタイプの料金を固定値で埋め込みましたが、AWS Pricing API を利用することで、常に最新の料金に基づいた計算が自動で可能になります。
  • 通知内容の拡充: Slackへの通知に、インスタンスに付与されているタグ情報や、AWSコンソールへの直接リンクを追加すると、どのインスタンスが何の目的で稼働しているのかが一目で分かり、より管理しやすくなります。
  • 他サービスへの展開: 同様の仕組みを応用して、RDSインスタンスやEBSボリュームなど、他のAWSリソースのコスト監視にも展開できます。

ぜひ、これらの改善案も参考にして、ご自身の環境に合わせたカスタマイズを試してみてください。

山本 哲也 (記事一覧)

カスタマーサクセス部のインフラエンジニア。

山を走るのが趣味です。