I tried automatically recovering EC2 instances that failed Status Checks using Step Functions without Lambda

I tried automatically recovering EC2 instances that failed Status Checks using Step Functions without Lambda

I created a mechanism to automate EC2 Stop/Start and recovery verification manually for cases where EC2 Auto Recovery is not applicable or fails to recover, without using Lambda. Using CloudWatch Alarms, EventBridge, Step Functions, and SNS, I reproduced a failure by disabling the NIC with FIS, and confirmed the behavior from detection through recovery completion.
2026.09.05

This page has been translated by machine translation. View original

Introduction

EC2 Auto Recovery attempts to restore availability when system status checks fail due to underlying infrastructure issues. However, it does not cover cases where only the instance status check fails, and when Auto Recovery cannot complete the restoration, manual actions such as Stop/Start are required.

In this article, I automated this manual response using Step Functions triggered by a CloudWatch alarm. The Step Functions state machine handles EC2 API calls, waiting for stop/start completion, re-checking Status Check results, and conditional branching. I used FIS to disable a NIC and reproduce an Instance Status Check failure, then verified the recovery flow through the state machine definition and execution history.

What Was Tested

I will explain in order: the overall architecture, the state machine definition, and the actual behavior during fault injection with FIS.

Architecture

The EC2 StatusCheckFailed_Instance metric is monitored at 1-minute intervals, and the alarm transitions to ALARM when the threshold is exceeded for 2 consecutive data points. An EventBridge rule that picks up this state change triggers the state machine. The state machine then proceeds to stop and start the EC2 instance via API, and finally sends a notification via SNS.

Architecture where EC2 Status Check failure is detected, Step Functions performs Stop/Start and waits for Status Check recovery, then sends SNS notification

Inside the State Machine

There are two reasons why Lambda functions are not needed: AWS SDK integration and JSONata.

AWS SDK integration is a mechanism that allows you to call AWS APIs directly by simply writing the ARN in the Resource field of a Task state. By specifying arn:aws:states:::aws-sdk:ec2:describeInstances, you can retrieve EC2 instance information. In this state machine, the only calls made are to retrieve instance state and Status Check results, stop/start the instance, and send SNS notifications.

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

The other feature, JSONata, is used for state management and condition evaluation. When QueryLanguage is set to JSONata, the way state input/output is written changes. Only two fields are used: Arguments and Output. Choice conditions are written as JSONata expressions in Condition, and the Variable and comparison operator fields used in JSONPath cannot be used. Counters such as loop counts can be carried over by updating variables with Assign.

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

A stop-waiting loop can be written with three states: Wait, Task, and Choice. The following WaitForStop loop definition omits error Catch handling.

"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"
}

It waits 15 seconds, calls describeInstances again, and if the state is stopped it proceeds to start; otherwise it returns to waiting. The maximum check count is 40, so the design waits approximately 10 minutes for a normal stop before switching to a force stop. If the count reaches 40 again after a force stop, it notifies as a timeout. The maximum for waiting for running after startup is 20 times (approximately 5 minutes), and the maximum for waiting for Status Check recovery is 40 times (approximately 10 minutes).

Fault Injection and Recovery Results

To bring down the Status Check, I disabled the NIC via SSM from an FIS experiment template.

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

The NIC name ens5 is the value confirmed on a t4g.nano running Amazon Linux 2023 (ARM64). Device names vary by environment, so please verify on the target instance before running.

After the NIC was brought down, the alarm transitioned to ALARM and EventBridge started the state machine. The event passed by EventBridge contains the reason that the transition occurred because two data points at 1-minute intervals were met.

{
  "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"
    }
  }
}

The event time is in UTC, which corresponds to 02:44:13 JST.

EC2 console showing the CloudWatch alarm transitioning to ALARM with two data points exceeding the threshold displayed

The FIS experiment itself completed in 73 seconds, but the disabled NIC remained as-is. Even at 02:44:13 when the state machine started, the Instance status was still impaired and the Status Check had not recovered.

I arranged the states obtained from the state machine execution history in chronological order. Times are in JST. state indicates the instance state from describeInstances. inst and sys are the Instance / System statuses from describeInstanceStatus.

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

Since the initial GetEC2StatusCheck showed Instance as impaired and System as ok, EvaluateState branched to StopInstance. CheckStopStatus was satisfied in 2 iterations, CheckStartStatus in 1 iteration, and CheckRecoveryStatus in 9 iterations.

After startup, System recovered to ok first at 02:46:32, and Instance became ok approximately 46 seconds later at 02:47:18. The approximately 137 seconds of recovery waiting corresponds to waiting for the Instance side to finish initializing.

The execution status was SUCCEEDED. Of the total 139 events in the execution history, state entry events totaled 43: Task 17 times, Choice 13 times, Wait 12 times, and Pass 1 time. In this execution, the ForceStopInstance, StopTimeout, and RecoveryFailed paths were not taken. AlreadyRecovered, EscalateSystem, EscalateTerminated, and NotifyUnexpectedError were also not taken.

Segment Start → End (JST) Duration
FIS experiment (NIC disabled) 02:41:22.592 → 02:42:35.145 Approximately 73 seconds
Alarm detection 02:41:22.592 → 02:44:13.159 Approximately 171 seconds
Step Functions execution 02:44:13.337 → 02:47:18.507 185 seconds
Of which, waiting for Stop completion 02:44:14.195 → 02:44:44.743 Approximately 31 seconds
Of which, waiting for running after Start 02:44:45.804 → 02:45:01.252 Approximately 16 seconds
Of which, waiting for Status Check recovery 02:45:01.252 → 02:47:18.254 Approximately 137 seconds
From fault injection to recovery completion 02:41:22.592 → 02:47:18.507 Approximately 6 minutes

The total time from fault injection to recovery completion was approximately 6 minutes.

Monthly Running Costs

Running costs were calculated using Tokyo Region pricing.

Item Unit Price (Asia Pacific - Tokyo) Free Tier
Step Functions Standard state transitions 0.000025 USD / transition 4,000 transitions/month
CloudWatch metric alarm (standard resolution) 0.10 USD / alarm metric/month 10 alarm metrics/month
CloudWatch Logs Vended Logs ingestion (Standard) 0.76 USD / GB (first 10 TB) 5 GB/month
SNS email notifications 2.00 USD / 100,000 messages 1,000 messages/month

Unit prices are from the Step Functions, CloudWatch, and Amazon SNS pricing pages.

Not included in the estimate are the last two rows in the table—state machine log output and SNS notifications—as well as FIS experiment execution costs and the EC2 instance itself.

Assuming recovery occurs once per month, costs were calculated at list price. Since the state transitions counted from the execution results were 43 per execution, that is 43 transitions × 0.000025 USD = 0.001075 USD. The alarm costs 0.10 USD per alarm metric. The total comes to approximately 0.101 USD per month.

Since state transitions and alarms each have free tiers, a workload of this scale will fit within the free tier if allowance remains.

Summary

I was able to automatically recover an EC2 instance that failed a Status Check using Step Functions without Lambda.

By not using Lambda functions, there is no need to keep up with Lambda runtime updates or manage per-function deployments and configurations. Additionally, since not only the execution results of each state but also waits and branches are recorded in the Step Functions execution history, it becomes easier to check the progress of recovery and identify where failures occurred.

If you have been manually handling EC2 instance restarts in your operations, please consider this as one option for automating that response.

Reference Materials

The complete configuration for this setup is compiled into a CloudFormation template.

CloudFormation Template (Full)
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 Auto Recovery Succeeded",
                "Message": "{% 'EC2 Stop/Start auto recovery has completed.\\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 Auto Recovery Failed",
                "Message": "{% 'EC2 Stop/Start auto recovery timed out. Manual intervention is required.\\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 Stop Failed",
                "Message": "{% 'EC2 instance stop timed out (failed even after force stop). Manual intervention is required.\\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] Natural Recovery Confirmed",
                "Message": "{% 'EC2 instance is already in a healthy state (natural recovery).\\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 Failure",
                "Message": "{% 'SystemStatus is impaired. Recovery via Stop/Start may not be possible.\\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] Instance Terminated",
                "Message": "{% 'EC2 instance is in terminated or shutting-down state. Recovery is not possible.\\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] Unexpected Error",
                "Message": "{% 'An unexpected error occurred during Step Functions execution.\\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

Save the above template as ec2-recovery-test.yaml. Deploy it with the following commands, inject a fault with FIS, and finally delete the stack. The prerequisites before execution are as follows.

  • After deployment, a confirmation email will be sent to the notification address, so approve the SNS subscription
  • Since the template does not specify a subnet, run it in an account that has a default VPC
  • FIS fault injection is executed via SSM, so the instance needs to be able to reach SSM
  • The FIS target is only the test instance created by this template. Since this is an operation that brings down the NIC, run it in a test account
  • Use the latest version of AWS CLI (version at time of verification: aws-cli/2.36.38)
# Deploy
aws cloudformation create-stack \
  --stack-name ec2-recovery-test \
  --template-body file://ec2-recovery-test.yaml \
  --parameters ParameterKey=NotificationEmail,ParameterValue=<notification destination email address> \
  --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

# Disable NIC with FIS to trigger StatusCheckFailed_Instance
aws fis start-experiment \
  --experiment-template-id <output value of FISExperimentTemplateId> \
  --region ap-northeast-1

# To test only the integration from EventBridge to 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

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

Share this article

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