Status Check に失敗した EC2 を Lambda なしの Step Functions で自動復旧させてみた
はじめに
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 で結果を通知します。

ステートマシンの中身
Lambda 関数を書かずに済む理由は2つあります。AWS SDK 統合と JSONata です。
AWS SDK 統合は、Task ステートの Resource に ARN を書くだけで AWS の API を直接呼べる仕組みです。arn:aws:states:::aws-sdk:ec2:describeInstances を指定すれば、EC2 のインスタンス情報を取得できます。今回のステートマシンが呼んでいるのは、インスタンスの状態と Status Check の取得、停止・起動、SNS への通知だけです。
もう1つの JSONata は、状態の保持と条件判定に使っています。QueryLanguage を JSONata にすると、ステートの入出力の書き方が変わります。使うのは Arguments と Output の2フィールドです。Choice の条件は Condition に JSONata 式を書く形になり、JSONPath で使う Variable と比較演算子フィールドは使えません。ループ回数のようなカウンタは、Assign で変数を更新して持ち回せます。
停止待ちのループは 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 にあたります。

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 Functions、CloudWatch、Amazon 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








