Status Check に失敗した EC2 を Lambda なしの Step Functions で自動復旧させてみた

Status Check に失敗した EC2 を Lambda なしの Step Functions で自動復旧させてみた

EC2 Auto Recovery の対象外、または復旧に至らない場合に、手動で行う EC2 の Stop/Start と復旧確認を Lambda を使わずに自動化する仕組みを作りました。CloudWatch アラーム、EventBridge、Step Functions、SNS を使い、FIS で NIC を無効化して障害を再現し、検知から復旧完了までの動きを確認しました。
2026.09.05

はじめに

EC2 Auto Recovery は、基盤側の問題によるシステムステータスチェックの失敗に対して可用性の復旧を試みます。一方、インスタンスステータスチェックだけが失敗した場合は対象外であり、Auto Recovery が復旧に至らない場合も Stop/Start などの手動対応が必要です。

本記事では、この手動対応を CloudWatch アラームから Step Functions で自動化しました。Step Functions には、EC2 API の呼び出し、停止・起動の完了待ち、Status Check の再確認、条件分岐を記述しました。FIS で NIC を無効化して Instance Status Check の失敗を再現し、ステートマシンの定義と実行履歴から復旧の流れを確認しました。

検証内容

構成の全体像、ステートマシンの定義、FIS による障害再現時の実際の動きの順に説明します。

構成

EC2 の StatusCheckFailed_Instance メトリクスを1分周期で監視し、しきい値以上のデータポイントが2回続いたらアラームは ALARM に遷移します。この状態変化を拾った EventBridge ルールがステートマシンを起動します。あとはステートマシンが EC2 の API で停止と起動を進め、最後に SNS で結果を通知します。

EC2 の Status Check 失敗を検知し、Step Functions が Stop/Start と Status Check の復旧待ちを行って SNS 通知する構成

ステートマシンの中身

Lambda 関数を書かずに済む理由は2つあります。AWS SDK 統合と JSONata です。

AWS SDK 統合は、Task ステートの Resource に ARN を書くだけで AWS の API を直接呼べる仕組みです。arn:aws:states:::aws-sdk:ec2:describeInstances を指定すれば、EC2 のインスタンス情報を取得できます。今回のステートマシンが呼んでいるのは、インスタンスの状態と Status Check の取得、停止・起動、SNS への通知だけです。

https://docs.aws.amazon.com/step-functions/latest/dg/supported-services-awssdk.html

もう1つの JSONata は、状態の保持と条件判定に使っています。QueryLanguage を JSONata にすると、ステートの入出力の書き方が変わります。使うのは ArgumentsOutput の2フィールドです。Choice の条件は Condition に JSONata 式を書く形になり、JSONPath で使う Variable と比較演算子フィールドは使えません。ループ回数のようなカウンタは、Assign で変数を更新して持ち回せます。

https://docs.aws.amazon.com/step-functions/latest/dg/transforming-data.html

停止待ちのループは Wait、Task、Choice の3ステートで書けます。次に示す WaitForStop ループの定義では、エラー時の Catch を省いています。

"WaitForStop": {
  "Type": "Wait",
  "Seconds": 15,
  "Next": "CheckStopStatus"
},
"CheckStopStatus": {
  "Type": "Task",
  "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
  "Arguments": {
    "InstanceIds": ["${InstanceId}"]
  },
  "Assign": {
    "ec2State": "{% $states.result.Reservations[0].Instances[0].State.Name %}",
    "stopCheckCount": "{% $stopCheckCount + 1 %}"
  },
  "Next": "EvaluateStopStatus"
},
"EvaluateStopStatus": {
  "Type": "Choice",
  "Choices": [
    {
      "Condition": "{% $ec2State = 'stopped' %}",
      "Next": "StartInstance"
    },
    {
      "Condition": "{% $stopCheckCount >= 40 and $forceStopAttempted = false %}",
      "Next": "ForceStopInstance"
    },
    {
      "Condition": "{% $stopCheckCount >= 40 and $forceStopAttempted = true %}",
      "Next": "StopTimeout"
    }
  ],
  "Default": "WaitForStop"
}

15秒待って describeInstances を呼び直し、stopped になっていれば起動へ進み、そうでなければ待ちに戻ります。チェック回数の上限は40回なので、通常の停止を約10分待ってから Force 指定の停止へ切り替える設計です。Force 停止後も40回に達した場合は、タイムアウトとして通知します。起動後の running 待ちの上限は20回(約5分)、Status Check の復旧待ちの上限は40回(約10分)です。

障害注入と復旧結果

Status Check を落とすため、FIS の実験テンプレートから SSM 経由で NIC を無効化しました。

Actions:
  DisableNic:
    ActionId: aws:ssm:send-command
    Parameters:
      documentArn: !Sub arn:aws:ssm:${AWS::Region}::document/AWS-RunShellScript
      documentParameters: '{"commands":["sudo ip link set ens5 down"]}'
      duration: PT1M
    Targets:
      Instances: TargetEC2
StopConditions:
  - Source: none

NIC 名の ens5 は、Amazon Linux 2023(ARM64)の t4g.nano で確認した値です。デバイス名は環境によって異なるため、実行前に対象インスタンスで確認してください。

NIC を落とした後、アラームが ALARM に遷移し、EventBridge がステートマシンを起動しました。EventBridge が渡したイベントには、1分間隔のデータポイントが2つ揃って遷移したという理由が入っています。

{
  "detail-type": "CloudWatch Alarm State Change",
  "source": "aws.cloudwatch",
  "time": "2026-09-04T17:44:13Z",
  "detail": {
    "alarmName": "ec2-recovery-test-StatusCheckFailed-Instance",
    "state": {
      "value": "ALARM",
      "reason": "Threshold Crossed: 2 datapoints [1.0 (04/09/26 17:43:00), 1.0 (04/09/26 17:42:00)] were greater than or equal to the threshold (1.0).",
      "timestamp": "2026-09-04T17:44:13.159+0000"
    },
    "previousState": {
      "value": "OK",
      "reason": "Threshold Crossed: 1 datapoint [0.0 (04/09/26 17:34:00)] was not greater than or equal to the threshold (1.0).",
      "timestamp": "2026-09-04T17:35:13.157+0000"
    }
  }
}

イベントの time は UTC で、JST では 02:44:13 にあたります。

EC2 コンソールで CloudWatch アラームが ALARM に遷移し、しきい値を超えた2つのデータポイントが表示されている

FIS の実験そのものは73秒で完了しましたが、無効化した NIC はそのままでした。ステートマシンが起動した 02:44:13 の時点でも Instance のステータスは impaired で、Status Check は失敗したままでした。

ステートマシンの実行履歴から、各ステートが取得した状態を時系列に並べました。時刻は JST です。state は describeInstances のインスタンス状態を示します。inst と sys は describeInstanceStatus の Instance / System のステータスです。

02:44:13.731  GetEC2StatusCheck    state=running   inst=impaired      sys=ok
02:44:29.428  CheckStopStatus      state=stopping
02:44:44.743  CheckStopStatus      state=stopped
02:45:01.252  CheckStartStatus     state=running
02:45:16.453  CheckRecoveryStatus  state=running   inst=initializing  sys=initializing
02:45:31.693  CheckRecoveryStatus  state=running   inst=initializing  sys=initializing
02:45:46.903  CheckRecoveryStatus  state=running   inst=initializing  sys=initializing
02:46:02.113  CheckRecoveryStatus  state=running   inst=initializing  sys=initializing
02:46:17.327  CheckRecoveryStatus  state=running   inst=initializing  sys=initializing
02:46:32.541  CheckRecoveryStatus  state=running   inst=initializing  sys=ok
02:46:47.755  CheckRecoveryStatus  state=running   inst=initializing  sys=ok
02:47:03.062  CheckRecoveryStatus  state=running   inst=initializing  sys=ok
02:47:18.254  CheckRecoveryStatus  state=running   inst=ok            sys=ok

初回の GetEC2StatusCheck で Instance が impaired、System が ok だったため、EvaluateState は StopInstance へ分岐しました。CheckStopStatus は2回、CheckStartStatus は1回、CheckRecoveryStatus は9回で条件を満たしました。

起動後、System は先に 02:46:32 で ok へ戻り、Instance はその約46秒後の 02:47:18 で ok になりました。復旧待ちの約137秒は、Instance 側の初期化待ちにあたります。

実行のステータスは SUCCEEDED でした。実行履歴の全139イベントのうち、状態に入ったイベントは Task 17回・Choice 13回・Wait 12回・Pass 1回の計43回でした。今回の実行では、ForceStopInstance、StopTimeout、RecoveryFailed の各パスを通りませんでした。AlreadyRecovered、EscalateSystem、EscalateTerminated、NotifyUnexpectedError も同じく通りませんでした。

区間 起点 → 終点(JST) 所要時間
FIS 実験(NIC 無効化) 02:41:22.592 → 02:42:35.145 約73秒
アラーム検知 02:41:22.592 → 02:44:13.159 約171秒
Step Functions 実行 02:44:13.337 → 02:47:18.507 185秒
うち Stop 完了待ち 02:44:14.195 → 02:44:44.743 約31秒
うち Start 後の running 待ち 02:44:45.804 → 02:45:01.252 約16秒
うち Status Check 復旧待ち 02:45:01.252 → 02:47:18.254 約137秒
障害注入から復旧完了まで 02:41:22.592 → 02:47:18.507 約6分

障害注入から復旧完了までは約6分でした。

月額の維持費

維持費は東京リージョンの単価で計算しました。

項目 単価(アジアパシフィック・東京) 無料枠
Step Functions Standard の状態遷移 0.000025 USD / 遷移 月4,000遷移
CloudWatch メトリクスアラーム(標準解像度) 0.10 USD / アラームメトリクス月 月10アラームメトリクス
CloudWatch Logs の Vended Logs 取り込み(Standard) 0.76 USD / GB(最初の10TB) 月5GB
SNS の Email 通知 2.00 USD / 10万通 月1,000通

単価は Step FunctionsCloudWatchAmazon SNS の各料金ページの値です。

試算に含めないのは、表の下2行にあたるステートマシンのログ出力と SNS の通知、FIS 実験の実行料金、EC2 インスタンス本体の料金です。

前提として、復旧が月1回起きる場合の費用を定価で計算しました。実行結果で数えた状態遷移は1実行あたり43回なので、43回 × 0.000025 USD = 0.001075 USD です。アラームは1アラームメトリクスあたり 0.10 USD です。合計は月額約0.101 USD になりました。

状態遷移とアラームにはそれぞれ無料枠があるため、枠が残っていればこの規模は収まります。

まとめ

Status Check に失敗した EC2 を Lambda なしの Step Functions で自動復旧できました。

Lambda 関数を使わないため、Lambda ランタイムの更新への追従や関数単位のデプロイ・設定管理を不要にできます。また、各ステートの実行結果に加え、待機や分岐も Step Functions の実行履歴に残るため、復旧の進行状況や失敗した箇所を確認しやすくなりました。

EC2 インスタンスの再起動を手動で運用していた場合は、その対応を自動化する選択肢の一つとしてお試しください。

参考資料

今回の構成一式は CloudFormation テンプレートにまとめています。

CloudFormation テンプレート全文
AWSTemplateFormatVersion: '2010-09-09'
Description: 'EC2 Stop/Start Auto Recovery Test Environment'

Parameters:
  ProjectName:
    Type: String
    Default: ec2-recovery-test
  NotificationEmail:
    Type: String
    Default: alerts@example.com
  InstanceType:
    Type: String
    Default: t4g.nano
  LogRetentionDays:
    Type: Number
    Default: 7
  LatestAmiId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64

Resources:
  # ============================================================
  # EC2 Security Group (uses default VPC)
  # ============================================================
  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: !Sub ${ProjectName} - Outbound only for SSM
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-sg

  # ============================================================
  # EC2 IAM Role and Instance Profile
  # ============================================================
  EC2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${ProjectName}-ec2-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Sub ${ProjectName}-ec2-profile
      Roles:
        - !Ref EC2Role

  # ============================================================
  # EC2 Instance
  # ============================================================
  TestEC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: !Ref InstanceType
      ImageId: !Ref LatestAmiId
      IamInstanceProfile: !Ref EC2InstanceProfile
      SecurityGroupIds:
        - !Ref EC2SecurityGroup
      Tags:
        - Key: Name
          Value: !Sub ${ProjectName}-test-ec2

  # ============================================================
  # SNS Topic and Subscription
  # ============================================================
  AlertTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: !Sub ${ProjectName}-alerts

  EmailSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref AlertTopic
      Protocol: email
      Endpoint: !Ref NotificationEmail

  # ============================================================
  # CloudWatch Alarm
  # ============================================================
  StatusCheckAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmName: !Sub ${ProjectName}-StatusCheckFailed-Instance
      AlarmDescription: Alarm when StatusCheckFailed_Instance is >= 1
      Namespace: AWS/EC2
      MetricName: StatusCheckFailed_Instance
      Dimensions:
        - Name: InstanceId
          Value: !Ref TestEC2Instance
      Statistic: Maximum
      Period: 60
      EvaluationPeriods: 2
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: missing

  # ============================================================
  # EventBridge IAM Role
  # ============================================================
  EventBridgeRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${ProjectName}-eventbridge-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InvokeStepFunctions
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: states:StartExecution
                Resource: !GetAtt RecoveryStateMachine.Arn

  # ============================================================
  # EventBridge Rule
  # ============================================================
  RecoveryTriggerRule:
    Type: AWS::Events::Rule
    Properties:
      Name: !Sub ${ProjectName}-recovery-trigger
      Description: Trigger Step Functions when StatusCheckFailed alarm goes to ALARM
      EventPattern:
        source:
          - aws.cloudwatch
        detail-type:
          - CloudWatch Alarm State Change
        detail:
          alarmName:
            - !Ref StatusCheckAlarm
          state:
            value:
              - ALARM
      State: ENABLED
      Targets:
        - Arn: !GetAtt RecoveryStateMachine.Arn
          RoleArn: !GetAtt EventBridgeRole.Arn
          Id: RecoveryStateMachineTarget

  # ============================================================
  # Step Functions IAM Role
  # ============================================================
  StepFunctionsRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${ProjectName}-stepfunctions-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: states.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: EC2Operations
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ec2:StopInstances
                  - ec2:StartInstances
                Resource: !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${TestEC2Instance}
              - Effect: Allow
                Action:
                  - ec2:DescribeInstances
                  - ec2:DescribeInstanceStatus
                Resource: '*'
        - PolicyName: SNSPublish
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: sns:Publish
                Resource: !Ref AlertTopic
        - PolicyName: CloudWatchLogs
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogDelivery
                  - logs:GetLogDelivery
                  - logs:UpdateLogDelivery
                  - logs:DeleteLogDelivery
                  - logs:ListLogDeliveries
                  - logs:PutLogEvents
                  - logs:PutResourcePolicy
                  - logs:DescribeResourcePolicies
                  - logs:DescribeLogGroups
                Resource: '*'

  # ============================================================
  # Step Functions Log Group
  # ============================================================
  StepFunctionsLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/vendedlogs/states/${ProjectName}-ec2-recovery
      RetentionInDays: !Ref LogRetentionDays

  # ============================================================
  # Step Functions State Machine
  # ============================================================
  RecoveryStateMachine:
    Type: AWS::StepFunctions::StateMachine
    Properties:
      StateMachineName: !Sub ${ProjectName}-ec2-recovery
      StateMachineType: STANDARD
      RoleArn: !GetAtt StepFunctionsRole.Arn
      LoggingConfiguration:
        Level: ALL
        IncludeExecutionData: true
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt StepFunctionsLogGroup.Arn
      DefinitionSubstitutions:
        InstanceId: !Ref TestEC2Instance
        SnsTopicArn: !Ref AlertTopic
      DefinitionString: |
        {
          "Comment": "EC2 Stop/Start Auto Recovery Workflow",
          "QueryLanguage": "JSONata",
          "TimeoutSeconds": 2400,
          "StartAt": "InitializeVariables",
          "States": {
            "InitializeVariables": {
              "Type": "Pass",
              "Assign": {
                "stopCheckCount": 0,
                "startCheckCount": 0,
                "recoveryCheckCount": 0,
                "forceStopAttempted": false,
                "errorInfo": null
              },
              "Next": "GetEC2State"
            },
            "GetEC2State": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"]
              },
              "Assign": {
                "ec2State": "{% $states.result.Reservations[0].Instances[0].State.Name %}"
              },
              "Next": "GetEC2StatusCheck",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "GetEC2StatusCheck": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstanceStatus",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"]
              },
              "Assign": {
                "instanceStatus": "{% $count($states.result.InstanceStatuses) > 0 ? $states.result.InstanceStatuses[0].InstanceStatus.Status : 'unknown' %}",
                "systemStatus": "{% $count($states.result.InstanceStatuses) > 0 ? $states.result.InstanceStatuses[0].SystemStatus.Status : 'unknown' %}"
              },
              "Next": "EvaluateState",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "EvaluateState": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $systemStatus = 'impaired' %}",
                  "Next": "EscalateSystem"
                },
                {
                  "Condition": "{% $instanceStatus = 'ok' and $systemStatus = 'ok' %}",
                  "Next": "AlreadyRecovered"
                },
                {
                  "Condition": "{% $ec2State = 'terminated' or $ec2State = 'shutting-down' %}",
                  "Next": "EscalateTerminated"
                },
                {
                  "Condition": "{% $ec2State = 'stopping' %}",
                  "Next": "WaitForStop"
                },
                {
                  "Condition": "{% $ec2State = 'stopped' %}",
                  "Next": "StartInstance"
                },
                {
                  "Condition": "{% $ec2State = 'pending' %}",
                  "Next": "WaitForStart"
                },
                {
                  "Condition": "{% $ec2State = 'running' and $instanceStatus = 'impaired' and $systemStatus = 'ok' %}",
                  "Next": "StopInstance"
                }
              ],
              "Default": "StopInstance"
            },
            "StopInstance": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"],
                "Force": false
              },
              "Assign": {
                "stopCheckCount": 0
              },
              "Next": "WaitForStop",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "ForceStopInstance": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"],
                "Force": true
              },
              "Assign": {
                "forceStopAttempted": true,
                "stopCheckCount": 0
              },
              "Next": "WaitForStop",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "WaitForStop": {
              "Type": "Wait",
              "Seconds": 15,
              "Next": "CheckStopStatus"
            },
            "CheckStopStatus": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"]
              },
              "Assign": {
                "ec2State": "{% $states.result.Reservations[0].Instances[0].State.Name %}",
                "stopCheckCount": "{% $stopCheckCount + 1 %}"
              },
              "Next": "EvaluateStopStatus",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "EvaluateStopStatus": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $ec2State = 'stopped' %}",
                  "Next": "StartInstance"
                },
                {
                  "Condition": "{% $stopCheckCount >= 40 and $forceStopAttempted = false %}",
                  "Next": "ForceStopInstance"
                },
                {
                  "Condition": "{% $stopCheckCount >= 40 and $forceStopAttempted = true %}",
                  "Next": "StopTimeout"
                }
              ],
              "Default": "WaitForStop"
            },
            "StartInstance": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"]
              },
              "Assign": {
                "startCheckCount": 0
              },
              "Next": "WaitForStart",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "WaitForStart": {
              "Type": "Wait",
              "Seconds": 15,
              "Next": "CheckStartStatus"
            },
            "CheckStartStatus": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"]
              },
              "Assign": {
                "ec2State": "{% $states.result.Reservations[0].Instances[0].State.Name %}",
                "startCheckCount": "{% $startCheckCount + 1 %}"
              },
              "Next": "EvaluateStartStatus",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "EvaluateStartStatus": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $ec2State = 'running' %}",
                  "Next": "WaitForRecovery"
                },
                {
                  "Condition": "{% $startCheckCount >= 20 %}",
                  "Next": "RecoveryFailed"
                }
              ],
              "Default": "WaitForStart"
            },
            "WaitForRecovery": {
              "Type": "Wait",
              "Seconds": 15,
              "Next": "CheckRecoveryStatus"
            },
            "CheckRecoveryStatus": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstanceStatus",
              "Arguments": {
                "InstanceIds": ["${InstanceId}"]
              },
              "Assign": {
                "instanceStatus": "{% $count($states.result.InstanceStatuses) > 0 ? $states.result.InstanceStatuses[0].InstanceStatus.Status : 'unknown' %}",
                "systemStatus": "{% $count($states.result.InstanceStatuses) > 0 ? $states.result.InstanceStatuses[0].SystemStatus.Status : 'unknown' %}",
                "recoveryCheckCount": "{% $recoveryCheckCount + 1 %}"
              },
              "Next": "EvaluateRecoveryStatus",
              "Catch": [
                {
                  "ErrorEquals": ["States.ALL"],
                  "Next": "NotifyUnexpectedError",
                  "Output": {
                    "errorInfo": "{% $states.errorOutput %}"
                  }
                }
              ]
            },
            "EvaluateRecoveryStatus": {
              "Type": "Choice",
              "Choices": [
                {
                  "Condition": "{% $instanceStatus = 'ok' and $systemStatus = 'ok' %}",
                  "Next": "RecoverySuccess"
                },
                {
                  "Condition": "{% $recoveryCheckCount >= 40 %}",
                  "Next": "RecoveryFailed"
                }
              ],
              "Default": "WaitForRecovery"
            },
            "RecoverySuccess": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] EC2自動復旧成功",
                "Message": "{% 'EC2 Stop/Start による自動復旧が完了しました。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nFinal State: ' & $ec2State & '\\nInstance Status: ' & $instanceStatus & '\\nSystem Status: ' & $systemStatus %}"
              },
              "End": true
            },
            "RecoveryFailed": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] EC2自動復旧失敗",
                "Message": "{% 'EC2 Stop/Start による自動復旧がタイムアウトしました。手動確認が必要です。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nFinal State: ' & $ec2State & '\\nInstance Status: ' & $instanceStatus & '\\nSystem Status: ' & $systemStatus %}"
              },
              "End": true
            },
            "StopTimeout": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] EC2停止失敗",
                "Message": "{% 'EC2 インスタンスの停止がタイムアウトしました(Force停止後も失敗)。手動確認が必要です。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nFinal State: ' & $ec2State %}"
              },
              "End": true
            },
            "AlreadyRecovered": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] 自然復旧確認",
                "Message": "{% 'EC2 インスタンスは既に正常状態です(自然復旧)。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nInstance Status: ' & $instanceStatus & '\\nSystem Status: ' & $systemStatus %}"
              },
              "End": true
            },
            "EscalateSystem": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] System障害",
                "Message": "{% 'SystemStatus が impaired です。Stop/Start では復旧できない可能性があります。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nInstance Status: ' & $instanceStatus & '\\nSystem Status: ' & $systemStatus %}"
              },
              "End": true
            },
            "EscalateTerminated": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] インスタンス終了",
                "Message": "{% 'EC2 インスタンスが terminated または shutting-down 状態です。復旧不可能です。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nState: ' & $ec2State %}"
              },
              "End": true
            },
            "NotifyUnexpectedError": {
              "Type": "Task",
              "Resource": "arn:aws:states:::aws-sdk:sns:publish",
              "Arguments": {
                "TopicArn": "${SnsTopicArn}",
                "Subject": "[TEST] 予期せぬエラー",
                "Message": "{% 'Step Functions 実行中に予期せぬエラーが発生しました。\\n\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id & '\\nError: ' & $string($errorInfo) %}"
              },
              "End": true
            }
          }
        }

  # ============================================================
  # FIS IAM Role
  # ============================================================
  FISRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${ProjectName}-fis-role
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: fis.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: FISSSMPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ssm:SendCommand
                  - ssm:GetCommandInvocation
                  - ssm:ListCommands
                  - ssm:ListCommandInvocations
                  - ssm:CancelCommand
                Resource: '*'
              - Effect: Allow
                Action:
                  - ec2:DescribeInstances
                  - ec2:DescribeInstanceStatus
                Resource: '*'

  # ============================================================
  # FIS Experiment Template
  # ============================================================
  NicDisableExperiment:
    Type: AWS::FIS::ExperimentTemplate
    Properties:
      Description: NIC disable to trigger StatusCheckFailed_Instance
      Actions:
        DisableNic:
          ActionId: aws:ssm:send-command
          Parameters:
            documentArn: !Sub arn:aws:ssm:${AWS::Region}::document/AWS-RunShellScript
            documentParameters: '{"commands":["sudo ip link set ens5 down"]}'
            duration: PT1M
          Targets:
            Instances: TargetEC2
      Targets:
        TargetEC2:
          ResourceType: aws:ec2:instance
          ResourceArns:
            - !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${TestEC2Instance}
          SelectionMode: ALL
      StopConditions:
        - Source: none
      RoleArn: !GetAtt FISRole.Arn
      Tags:
        Name: !Sub ${ProjectName}-nic-disable

Outputs:
  EC2InstanceId:
    Description: EC2 Instance ID
    Value: !Ref TestEC2Instance
  EC2InstancePublicDnsName:
    Description: EC2 Instance Public DNS Name
    Value: !GetAtt TestEC2Instance.PublicDnsName
  SecurityGroupId:
    Description: Security Group ID
    Value: !Ref EC2SecurityGroup
  SNSTopicArn:
    Description: SNS Topic ARN
    Value: !Ref AlertTopic
  CloudWatchAlarmName:
    Description: CloudWatch Alarm Name
    Value: !Ref StatusCheckAlarm
  EventBridgeRuleName:
    Description: EventBridge Rule Name
    Value: !Ref RecoveryTriggerRule
  StepFunctionsStateMachineArn:
    Description: Step Functions State Machine ARN
    Value: !GetAtt RecoveryStateMachine.Arn
  StepFunctionsLogGroupName:
    Description: Step Functions Log Group Name
    Value: !Ref StepFunctionsLogGroup
  FISExperimentTemplateId:
    Description: FIS Experiment Template ID
    Value: !Ref NicDisableExperiment

上のテンプレートを ec2-recovery-test.yaml として保存します。次のコマンドでデプロイし、FIS で障害を注入し、最後にスタックを削除します。実行前の前提は4点です。

  • デプロイ後に通知先のアドレスへ確認メールが届くので、SNS サブスクリプションを承認する
  • テンプレートはサブネットを指定していないため、デフォルト VPC があるアカウントで実行する
  • FIS の障害注入は SSM 経由で実行するので、インスタンスから SSM に到達できる必要がある
  • FIS の対象はこのテンプレートで作成した検証用インスタンスだけ。NIC を落とす操作なので検証用アカウントで実行する
  • AWS CLI は最新版を使う(検証時のバージョンは aws-cli/2.36.38)
# デプロイ
aws cloudformation create-stack \
  --stack-name ec2-recovery-test \
  --template-body file://ec2-recovery-test.yaml \
  --parameters ParameterKey=NotificationEmail,ParameterValue=<通知先メールアドレ> \
  --capabilities CAPABILITY_NAMED_IAM \
  --deployment-config '{"mode": "EXPRESS"}' \
  --region ap-northeast-1

aws cloudformation wait stack-create-complete \
  --stack-name ec2-recovery-test \
  --region ap-northeast-1

# FIS で NIC を無効化して StatusCheckFailed_Instance を発生させる
aws fis start-experiment \
  --experiment-template-id <FISExperimentTemplateId の出力> \
  --region ap-northeast-1

# EventBridge から Step Functions までの連携だけを試す場合
aws cloudwatch set-alarm-state \
  --alarm-name ec2-recovery-test-StatusCheckFailed-Instance \
  --state-value ALARM \
  --state-reason "Manual test trigger" \
  --region ap-northeast-1

# 撤去
aws cloudformation delete-stack \
  --stack-name ec2-recovery-test \
  --region ap-northeast-1

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事