EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止させてみた

Step Functionsを組み合わせば痒い所に手が届く
2023.09.07

定期起動・停止するEC2インスタンスをタグで指定したい

こんにちは、のんピ(@non____97)です。

皆さんは定期起動・停止するEC2インスタンスをタグで指定したいなと思ったことはありますか? 私はあります。

EventBridge SchedulerでStopInstancesやStartInstancesを定期実行することで、簡単にEC2インスタンスを定期起動・停止させることが可能です。

ただし、どちらのAPIもインスタンスIDを指定する必要があります。

インスタンスIDを指定するとなると、対象となるインスタンスが増えた場合やリストアした際にインスタンスIDが変更となった場合など都度EventBridge Schedulerのペイロードを変更する必要があるため、非常に手間です。タグを使ってなるべく楽に指定したいところです。

そこで、EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止する仕組みを実装してみました。

処理のフロー

指定したタグを全て含む場合と、指定したタグのいずれかが付与されている場合のどちらにも対応できるようにします。

Step Functionsのワークフローは以下のとおりです。

  1. ステートマシンのインプットでand(指定したタグを全て含む場合)か、or(指定したタグのいずれかが付与されている場合)のどちらか判定
  2. 以下のいずれのか処理を実施
    • 指定したタグを全て含む場合、Filterにインプットで指定された値を指定してDescribeInstancesを実行し、インスタンスIDの配列と配列の長さを結果に保存
    • 指定したタグのいずれかが付与されている場合、インプットで指定されたタグでループしてDescribeInstancesを実行、インスタンスIDの配列配列の長さを結果に保存
  3. 配列の長さが0の場合は、該当するEC2インスタンスが存在しないと判断し、処理を終了
  4. ステートマシンのインプットで指定したActionStopの場合、StopInstancesを実行
  5. ステートマシンのインプットで指定したActionStartの場合、StartpInstancesを実行

図示すると以下の通りです。

ステートマシンのワークフロー

AWS CDKでデプロイしました。使用したコードは以下リポジトリに保存しています。

ステートマシン周りは以下の通りです。

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

export interface SfnProps {}

export class Sfn extends Construct {
  readonly stateMachine: cdk.aws_stepfunctions.StateMachine;

  constructor(scope: Construct, id: string, props?: SfnProps) {
    super(scope, id);

    // 複数のタグが付与されている場合はループさせる
    // ループされた結果はフラットな配列に変換
    // インスタンスIDはユニークになるように設定
    // マッチするインスタンスが存在したかどうか判断するために length を結果に追加
    const mapTags = new cdk.aws_stepfunctions.Map(this, "MapTags", {
      itemsPath: cdk.aws_stepfunctions.JsonPath.stringAt("$.Tags.or"),
      resultSelector: {
        InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt(
          "States.ArrayUnique($[*][*][*])"
        ),
        length: cdk.aws_stepfunctions.JsonPath.stringAt(
          "States.ArrayLength(States.ArrayUnique($[*][*][*]))"
        ),
      },
    });

    // 指定したタグが付与されているEC2 InstanceのID取得
    const instanceIdsTagSummation =
      new cdk.aws_stepfunctions_tasks.CallAwsService(
        this,
        "DescribeInstancesTagSummation",
        {
          service: "ec2",
          action: "describeInstances",
          iamResources: ["*"],
          resultSelector: {
            InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt(
              "$.Reservations[*].Instances[*].InstanceId"
            ),
          },
          parameters: {
            Filters: [
              {
                Name: cdk.aws_stepfunctions.JsonPath.stringAt(
                  "States.Format('tag:{}', $.Key)"
                ),
                Values: cdk.aws_stepfunctions.JsonPath.stringAt("$.Values"),
              },
            ],
          },
        }
      );

    // 複数の指定したタグが付与されているEC2 InstanceのID取得
    const instanceIdsTagProduct =
      new cdk.aws_stepfunctions_tasks.CallAwsService(
        this,
        "DescribeInstancesTagProduct",
        {
          service: "ec2",
          action: "describeInstances",
          iamResources: ["*"],
          resultSelector: {
            InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt(
              "$.Reservations[*].Instances[*].InstanceId"
            ),
            length: cdk.aws_stepfunctions.JsonPath.stringAt(
              "States.ArrayLength($.Reservations[*].Instances[*].InstanceId)"
            ),
          },
          parameters: {
            Filters: cdk.aws_stepfunctions.JsonPath.stringAt("$.Tags.and"),
          },
        }
      );

    // タグがAND か OR かの判定
    const choiceTag = new cdk.aws_stepfunctions.Choice(this, "ChoiceTag")
      .when(cdk.aws_stepfunctions.Condition.isPresent("$.Tags.or"), mapTags)
      .when(
        cdk.aws_stepfunctions.Condition.isPresent("$.Tags.and"),
        instanceIdsTagProduct
      );

    // EC2 Instanceの停止
    const stopInstances = new cdk.aws_stepfunctions_tasks.CallAwsService(
      this,
      "StopInstances",
      {
        service: "ec2",
        action: "stopInstances",
        iamResources: ["*"],
        parameters: {
          InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt("$.InstanceIds"),
        },
      }
    );

    // EC2 Instanceの起動
    const startInstances = new cdk.aws_stepfunctions_tasks.CallAwsService(
      this,
      "StartInstances",
      {
        service: "ec2",
        action: "startInstances",
        iamResources: ["*"],
        parameters: {
          InstanceIds: cdk.aws_stepfunctions.JsonPath.stringAt("$.InstanceIds"),
        },
      }
    );

    // 指定した条件にマッチするEC2 Instanceが存在しない場合用のステート
    const pass = new cdk.aws_stepfunctions.Pass(this, "Pass");

    // EC2 Instanceの起動 or 停止
    const choiceAction = new cdk.aws_stepfunctions.Choice(this, "ChoiceAction")
      .when(
        // cdk.aws_stepfunctions.Condition.stringEquals("$.InstanceIds", ""),
        cdk.aws_stepfunctions.Condition.numberEquals("$.length", 0),
        pass
      )
      .when(
        cdk.aws_stepfunctions.Condition.stringEquals(
          "$$.Execution.Input.Action",
          "Stop"
        ),
        stopInstances
      )
      .when(
        cdk.aws_stepfunctions.Condition.stringEquals(
          "$$.Execution.Input.Action",
          "Start"
        ),
        startInstances
      );

    // ワークフローの定義
    const definition = choiceTag;
    mapTags.iterator(instanceIdsTagSummation).next(choiceAction);
    instanceIdsTagProduct.next(choiceAction);
    pass.endStates;

    // StepFunctions ステートマシンの作成
    this.stateMachine = new cdk.aws_stepfunctions.StateMachine(
      this,
      "Default",
      {
        definitionBody:
          cdk.aws_stepfunctions.DefinitionBody.fromChainable(definition),
        timeout: cdk.Duration.minutes(5),
      }
    );
  }
}

ASLにすると以下の通りです。

{
  "StartAt": "ChoiceTag",
  "States": {
    "ChoiceTag": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.Tags.or",
          "IsPresent": true,
          "Next": "MapTags"
        },
        {
          "Variable": "$.Tags.and",
          "IsPresent": true,
          "Next": "DescribeInstancesTagProduct"
        }
      ]
    },
    "MapTags": {
      "Type": "Map",
      "Next": "ChoiceAction",
      "ResultSelector": {
        "InstanceIds.$": "States.ArrayUnique($[*][*][*])",
        "length.$": "States.ArrayLength(States.ArrayUnique($[*][*][*]))"
      },
      "Iterator": {
        "StartAt": "DescribeInstancesTagSummation",
        "States": {
          "DescribeInstancesTagSummation": {
            "End": true,
            "Type": "Task",
            "ResultSelector": {
              "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId"
            },
            "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
            "Parameters": {
              "Filters": [
                {
                  "Name.$": "States.Format('tag:{}', $.Key)",
                  "Values.$": "$.Values"
                }
              ]
            }
          }
        }
      },
      "ItemsPath": "$.Tags.or"
    },
    "ChoiceAction": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.length",
          "NumericEquals": 0,
          "Next": "Pass"
        },
        {
          "Variable": "$$.Execution.Input.Action",
          "StringEquals": "Stop",
          "Next": "StopInstances"
        },
        {
          "Variable": "$$.Execution.Input.Action",
          "StringEquals": "Start",
          "Next": "StartInstances"
        }
      ]
    },
    "DescribeInstancesTagProduct": {
      "Next": "ChoiceAction",
      "Type": "Task",
      "ResultSelector": {
        "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId",
        "length.$": "States.ArrayLength($.Reservations[*].Instances[*].InstanceId)"
      },
      "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
      "Parameters": {
        "Filters.$": "$.Tags.and"
      }
    },
    "Pass": {
      "Type": "Pass",
      "End": true
    },
    "StopInstances": {
      "End": true,
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
      "Parameters": {
        "InstanceIds.$": "$.InstanceIds"
      }
    },
    "StartInstances": {
      "End": true,
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
      "Parameters": {
        "InstanceIds.$": "$.InstanceIds"
      }
    }
  },
  "TimeoutSeconds": 300
}

ポイントは以下です。

  1. Mapの結果の配列をフラットにする
  2. 配列が空かどうか判断するために、前段のステートで配列の長さを計算しておく

1つ目について、Mapで何もしなければ、以下のように結果が配列で返ってきてしまいます。この形だと後続でもループさせる必要があったりと処理が面倒です。

[
  {
    "InstanceIds": [
      "インスタンスID",
    ]
  },
  {
    "InstanceIds": [
      "インスタンスID",
    ]
  }
]

そのため、ResultSelector$[*][*][*]として配列をフラットにしてあげます。

こちらはAWS公式ドキュメントにも記載があります。

マッピングステートマシンの 並行 or ステートが配列を返す場合は、ResultSelectorそれらをフィールドを含むフラット配列に変換できます。このフィールドをパラレルまたはマップステートの定義に含めると、これらのステートの結果を操作できます。

配列をフラット化するには、次の例に示すように、[*]ResultSelectorフィールドで JMESPath 構文を使用します。

"ResultSelector": {
    "flattenArray.$": "$[*][*]"
  }

InputPath、パラメータ、ResultSelector - AWS Step Functions

2つ目については、Choiceの使用として判定に使用する変数は必ずJSONパスである必要があります。変数にStates.ArrayLength($.InstanceIds)と値を指定することはできません。

そのため、前段のステートのResultSelectorStates.ArrayLength(States.ArrayUnique($[*][*][*]))などと配列の長さを計算してあげています。

動作確認

動作確認をします。

EventBridge Schedulerで以下のようなスケジュールを設定します。

  • 0/10分ごとにInstance : Instance Aまたはtest key : test valueのいずれかのタグが付与されている場合はEC2インスタンスを停止

    EventBridge Schedulerのペイロード

    {
      "Tags": {
        "or": [
          {
            "Key": "Instance",
            "Values": [
              "Instance A"
            ]
          },
          {
            "Key": "test key",
            "Values": [
              "test value"
            ]
          }
        ]
      },
      "Action": "Stop"
    }
  • 5/10分ごとにInstance : Instance Atest key : test valueのタグがどちらも付与されている場合はEC2インスタンスを起動

    EventBridge Schedulerのペイロード

    {
      "Tags": {
        "and": [
          {
            "Name": "tag:Instance",
            "Values": [
              "Instance A"
            ]
          },
          {
            "Name": "tag:test key",
            "Values": [
              "test value"
            ]
          }
        ]
      },
      "Action": "Start"
    }

検証で使用するEC2インスタンスの一覧は以下の通りです。

EC2インスタンス一覧

11:40になりました。EC2インスタンスが停止しているか確認しましょう。

いずれのEC2インスタンスにもtest key : test valueのタグが付与されているため、全てのEC2インスタンスが停止していますね。

全てのEC2インスタンスが停止していることを確認

ワークフローも確認します。

EC2インスタンスの停止のフロー

タグでDescribeInstancesTagSummationをMapしてStopInstancesを実行していることが分かります。また、StopInstancesの入力としてインスタンスIDとインスタンスIDの数が指定されていますね。

11:45になりました。EC2インスタンスが起動していることを確認します。

Instance : Instance Atest key : test valueのタグがどちらも付与されているEC2インスタンスのみ起動していますね。

全てのタグが付与されているE2インスタンスが起動していることを確認

ワークフローも確認します。

EC2インスタンスの起動のフロー

タグでDescribeInstancesTagProductを実行した後、StartInstancesを実行していることが分かります。また、StartInstancesの入力としてインスタンスIDとインスタンスIDの数が指定されていますね。

Step Functionsを組み合わせば痒い所に手が届く

EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止させてみました。

Step Functionsをうまく使えば痒い所に手が届くので非常に便利ですね。

ちなみに、opswitchを使えば、このような作り込みをしなくとも対応可能です。「Step Functionsで頑張るのもな...」という方は是非触ってみてください。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!