EC2 アプリケーションステータスチェックと Step Functions で Nginx の2段階自動復旧を試してみた
はじめに
2026年8月、EC2 にアプリケーションステータスチェックが提供開始されました。VPC 内から監視対象のインスタンスへ HTTP リクエストを送り、その応答でアプリ層の状態を判定する機能です。
先行記事では、Instance Status Check の失敗を受けて Step Functions で EC2 を Stop/Start する構成を扱いました。
本記事では、Nginx の HTTP 応答をこのチェックに任せ、異常を検知したら Step Functions が2段階で復旧する仕組みを作ります。1段階目は SSM による Nginx の再起動、2段階目は EC2 の Stop/Start です。Lambda 関数は使わず、AWS SDK 統合と JSONata だけで組みました。
検証内容
構成、監視が始まるまでの経過、Nginx を停止した場合、再起動できない場合の順に説明します。
構成
アプリケーションステータスチェックは、2026年8月にリリースされた機能です。チェックの作成からステータス遷移、マネージド ENI の仕組みまでは別記事で検証しています。
監視対象のインスタンスへ HTTP GET を送り、チェックが失敗すると、CloudWatch メトリクスの StatusCheckFailed_Application に記録されます。CloudWatch アラームと EventBridge を経由して、Step Functions の実行が始まります。ステートマシンは最初に SSM で Nginx を再起動し、失敗した場合だけ EC2 を Stop/Start して、結果を SNS へ通知します。

検証環境は CloudFormation テンプレート1つにまとめました。作成するリソースは、EC2 インスタンス、アプリケーションステータスチェック、CloudWatch アラーム、SNS トピックとメールサブスクリプション、EventBridge のルール、36ステートの Step Functions ステートマシン、CloudWatch Logs のロググループ、これらに必要な IAM ロールとセキュリティグループです。EC2 インスタンスは Amazon Linux 2023 ARM64 の t4g.nano で、UserData で Nginx を導入しています。
テンプレートで押さえる点を、対応する定義とともに紹介します。
ヘルスチェックの送信元と宛先
HealthCheckPaths でヘルスチェックの送信元と宛先を指定します。送信元にはマネージド ENI を置くサブネットとセキュリティグループ、宛先には監視するインスタンスのサブネットとセキュリティグループを指定します。Source と Destinations の両方でサブネットの指定が必要です。
HealthCheckPaths:
- Source:
SecurityGroupId: !GetAtt AppStatusCheckSourceSG.GroupId
SubnetId: !Ref SubnetId
Destinations:
- SecurityGroupId: !GetAtt EC2SecurityGroup.GroupId
SubnetId: !Ref SubnetId
セキュリティグループ ID の指定
セキュリティグループ ID は、!GetAtt <論理ID>.GroupId で取得します。
SecurityGroupId: !GetAtt AppStatusCheckSourceSG.GroupId
宛先側のインバウンドルール
宛先インスタンスのセキュリティグループには、送信元セキュリティグループからのポート80を許可します。
EC2SecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !Ref EC2SecurityGroup
IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !GetAtt AppStatusCheckSourceSG.GroupId
アプリケーションステータスチェック ID の出力
!Ref ApplicationStatusCheck は ARN を返します。ID が必要な場所では !GetAtt ApplicationStatusCheck.ApplicationStatusCheckId を使います。このテンプレートでは、関連付けコマンドで使う ID を Outputs へ出力しています。
ApplicationStatusCheckId:
Value: !GetAtt ApplicationStatusCheck.ApplicationStatusCheckId
ステートマシン全体のタイムアウト
ステートマシンに TimeoutSeconds を指定しました。今回は1800秒です。Stop/Start の完了待ちループが想定外に回り続けた場合は打ち切ります。
"TimeoutSeconds": 1800,
復旧は SSM の Run Command で行うため、EC2 を置くサブネットは SSM のエンドポイントへ到達できる必要があります。UserData での Nginx の導入も、リポジトリへ到達できることが前提です。パブリックサブネットとパブリック IP の組み合わせ、NAT ゲートウェイ、SSM 用の VPC エンドポイントのいずれかを用意してください。
送信元の指定を省略すると AWS マネージドネットワークパスになり、送信元は AWS が選びます。その場合、describe-application-status-checks の戻り値に送信元セキュリティグループは含まれません。宛先側の許可はセキュリティグループではなく CIDR で書くことになります。
テンプレート全文とデプロイ方法は、記事末尾に掲載しています。
デプロイが終わったら、通知先のアドレスに届く SNS サブスクリプションの確認メールを承認します。
続いてアプリケーションステータスチェックをインスタンスへ関連付けます。チェック自体は AWS::EC2::ApplicationStatusCheck で作成できます。インスタンスへの関連付けに対応するリソースタイプは提供されていないため、関連付けは API で実行します。関連付けの対象は、インスタンス ID とタグのどちらでも指定できます。ここでは CloudFormation が自動で付与する aws:cloudformation:stack-id タグを使いました。値がスタックの ARN で UUID を含むため、同じ名前でスタックを作り直しても、以前の関連付けが新しいインスタンスに一致することはありません。
STACK_ID=$(aws cloudformation describe-stacks \
--stack-name ec2-app-recovery-test \
--query 'Stacks[0].StackId' \
--output text --region ap-northeast-1)
ASC_ID=$(aws cloudformation describe-stacks \
--stack-name ec2-app-recovery-test \
--query 'Stacks[0].Outputs[?OutputKey==`ApplicationStatusCheckId`].OutputValue' \
--output text --region ap-northeast-1)
aws ec2 associate-application-status-check \
--application-status-check-id $ASC_ID \
--target-tag-associations Key=aws:cloudformation:stack-id,Value=$STACK_ID \
--region ap-northeast-1
応答では、関連付けの種類が EC2TAG として返りました。
{
"SuccessfulResults": [
{
"ApplicationStatusCheckId": "asc-xxxxxxxx",
"AssociationType": "EC2TAG",
"AssociationValue": "aws:cloudformation:stack-id=arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxxxx:stack/ec2-app-recovery-test/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
],
"UnsuccessfulResults": []
}
一覧を取得すると、同じ関連付けが tag として登録されています。
{
"Associations": [
{
"ApplicationStatusCheckId": "asc-xxxxxxxx",
"AssociationType": "tag",
"Key": "aws:cloudformation:stack-id",
"Value": "arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxxxx:stack/ec2-app-recovery-test/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
]
}
監視の開始
アプリケーションステータスチェックのステータスは initializing で、約5分後に ok へ変わりました。時刻は JST です。
| 時刻 | 全体のステータス |
|---|---|
| 08:03:55 | initializing |
| 08:09:00 | ok |
この待ち時間は初期化の猶予期間によるものです。InitializationGracePeriodSeconds の既定は300秒で、有効範囲は1〜600秒です。上の経過は既定の300秒で計測したものです。検証後、初期化の猶予期間を60秒へ変更しました。掲載したテンプレートは60秒を指定しています。変更後の設定値は次のとおりで、順に初期化の猶予期間、タイムアウト、失敗しきい値、成功しきい値、チェック間隔です。
$ aws ec2 describe-application-status-checks --application-status-check-ids asc-xxxxxxxx \
--query 'ApplicationStatusChecks[].[InitializationGracePeriodSeconds,Timeout,FailureThreshold,SuccessThreshold,Interval]'
60 6 2 2 60
チェック間隔は60秒固定で変更できません(ユーザーガイドの Default settings 表に Check interval | 60 seconds (fixed; not configurable) とあります)。CloudFormation の Interval も有効値は60のみです。
正常時のレスポンスを確認しました。
{
"ApplicationStatuses": {
"Instances": [
{
"InstanceId": "i-xxxxxxxxxxxxxxxxx1",
"AvailabilityZone": "apne1-az4",
"ApplicationStatus": {
"Status": "ok",
"StatusTimeStamp": "2026-09-04T23:11:55.674000+00:00",
"StatusSince": "2026-09-04T23:09:00+00:00",
"Details": [
{
"ApplicationStatusCheckId": "asc-xxxxxxxx",
"CheckUpdateTime": "2026-09-04T23:11:55.674000+00:00",
"Aggregation": "included",
"Status": "passed",
"StatusTimeStamp": "2026-09-04T23:11:55.674000+00:00",
"StatusSince": "2026-09-04T23:09:00+00:00",
"Reason": {
"Code": "ResponseCodeMatched",
"StatusCode": 200,
"Protocol": "HTTP"
}
}
]
}
}
]
}
}
全体のステータスは ok、チェック個別の判定は passed で、理由コードは応答コードが一致したことを示す ResponseCodeMatched でした。この後にインスタンスを置き換えたため、以降の検証ではインスタンス ID が別のものになっています。
Nginx 停止
1段階目の復旧を通すために、SSM の Run Command で Nginx を停止しました。
aws ssm send-command \
--instance-ids i-xxxxxxxxxxxxxxxxx2 \
--document-name "AWS-RunShellScript" \
--parameters 'commands=["sudo systemctl stop nginx"]' \
--region ap-northeast-1
アプリケーションステータスは impaired に変わりました。
{
"ApplicationStatuses": {
"Instances": [
{
"InstanceId": "i-xxxxxxxxxxxxxxxxx2",
"AvailabilityZone": "apne1-az4",
"ApplicationStatus": {
"Status": "impaired",
"StatusTimeStamp": "2026-09-04T23:28:07.456000+00:00",
"StatusSince": "2026-09-04T23:29:00+00:00",
"Details": [
{
"ApplicationStatusCheckId": "asc-xxxxxxxx",
"CheckUpdateTime": "2026-09-04T23:28:07.456000+00:00",
"Aggregation": "included",
"Status": "failed",
"StatusTimeStamp": "2026-09-04T23:28:07.456000+00:00",
"StatusSince": "2026-09-04T23:29:00+00:00",
"Reason": {
"Code": "ConnectionRefused",
"StatusCode": 0
}
}
]
}
}
]
}
}
待ち受けるプロセスがいない状態は、理由コード ConnectionRefused として現れました。
アラームから起動した Step Functions の実行では、実行されたステートは15個で、履歴のイベント数は53件でした。
08:30:20.034 Init
08:30:20.034 CheckNginxInit
08:30:20.166 WaitCheckInit
08:30:25.218 GetCheckInitResult
08:30:25.433 EvalInitStatus
08:30:25.433 SSMRestart
08:30:25.576 WaitSSM
08:30:35.632 CheckSSM
08:30:35.743 EvalSSM
08:30:35.743 WaitAfterRestart
08:30:45.785 CheckNginxAfterRestart
08:30:45.939 WaitCheckAfterRestart
08:30:50.996 GetCheckAfterRestartResult
08:30:51.138 EvalStage1
08:30:51.138 NotifyStage1OK
SSM で systemctl is-active nginx を実行して復旧を確認し、NotifyStage1OK で終わりました。実行グラフでも、通ったのは1段階目のステートだけでした。

障害注入から復旧完了までは4分27秒でした。
| 時刻 | イベント |
|---|---|
| 08:26:24 | SSM で Nginx を停止 |
| 08:29:00 | アプリケーションステータスが impaired(ConnectionRefused) |
| 08:30:19 | CloudWatch アラームが OK から ALARM へ |
| 08:30:20 | Step Functions の実行開始 |
| 08:30:51 | NotifyStage1OK で終了(実行時間31秒) |
| 08:32:19 | アラームが ALARM から OK へ |
内訳を見ると、検知までが約2分半、アラームの遷移から復旧完了までは1分未満でした。
SSM による再起動に失敗する状態
2段階目の復旧を通すために、Nginx を停止したうえで、接続は受け付けるが応答を返さないプロセスにポート80を占有させました。この状態では Nginx を起動できないため、SSM による再起動は失敗します。プロセスは永続化していないので、Stop/Start 後は消えます。
ポート80を占有するパラメータファイル
{
"commands": [
"sudo systemctl stop nginx",
"printf '%s\\n' 'import socket' 's=socket.socket()' 's.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)' 's.bind((\"0.0.0.0\",80))' 's.listen(5)' 'while True:' ' c,a=s.accept()' | sudo tee /tmp/hog.py",
"sudo setsid nohup python3 /tmp/hog.py >/tmp/hog.log 2>&1 < /dev/null &",
"sleep 3",
"sudo ss -ltnp | grep ':80' || echo 'NOT LISTENING'",
"systemctl is-active nginx || true"
]
}
上のパラメータを port80-hog-params.json として保存し、Run Command に渡しました。
aws ssm send-command \
--instance-ids i-xxxxxxxxxxxxxxxxx2 \
--document-name "AWS-RunShellScript" \
--parameters file://port80-hog-params.json \
--region ap-northeast-1
実行結果の標準出力の末尾です。ポート80は python3 が握っており、Nginx は停止しています。
LISTEN 0 5 0.0.0.0:80 0.0.0.0:* users:(("python3",pid=26438,fd=3))
inactive
アプリケーションステータスは前節と同じ impaired ですが、理由コードは違いました。TCP 接続は確立するものの、HTTP 応答が返らないため、ConnectionRefused ではなく ResponseTimeout でした。
{
"ApplicationStatuses": {
"Instances": [
{
"InstanceId": "i-xxxxxxxxxxxxxxxxx2",
"AvailabilityZone": "apne1-az4",
"ApplicationStatus": {
"Status": "impaired",
"StatusTimeStamp": "2026-09-04T23:35:09.514000+00:00",
"StatusSince": "2026-09-04T23:36:00+00:00",
"Details": [
{
"ApplicationStatusCheckId": "asc-xxxxxxxx",
"CheckUpdateTime": "2026-09-04T23:35:09.514000+00:00",
"Aggregation": "included",
"Status": "failed",
"StatusTimeStamp": "2026-09-04T23:35:09.514000+00:00",
"StatusSince": "2026-09-04T23:36:00+00:00",
"Reason": {
"Code": "ResponseTimeout",
"StatusCode": 0
}
}
]
}
}
]
}
}
この実行では、実行されたステートは23個で、履歴のイベント数は81件でした。
08:37:20.068 Init
08:37:20.068 CheckNginxInit
08:37:20.245 WaitCheckInit
08:37:25.296 GetCheckInitResult
08:37:25.484 EvalInitStatus
08:37:25.484 SSMRestart
08:37:25.640 WaitSSM
08:37:35.733 CheckSSM
08:37:35.869 EvalSSM
08:37:35.869 Stage2Stop
08:37:36.401 WaitStop
08:37:51.455 CheckStop
08:37:51.694 EvalStop
08:37:51.694 StartInstance
08:37:52.743 WaitStart
08:38:07.798 CheckStart
08:38:08.067 EvalStart
08:38:08.067 WaitRecovery
08:38:38.117 CheckNginxRecovery
08:38:38.270 WaitCheckRecovery
08:38:43.320 GetCheckRecoveryResult
08:38:43.480 EvalRecovery
08:38:43.480 NotifyStage2OK
CheckSSM が取得した SSM コマンドのステータスが Failed だったため、EvalSSM から Stage2Stop へ分岐しました。その後は停止の確認、起動、復旧確認を経て NotifyStage2OK で完了しました。
インスタンス側の状態も確認しました。起動時刻が Stop/Start の時刻に更新され、ポート80を占有していたプロセスは消えて、Nginx が待ち受けを取り戻していました。
$ uptime -s
2026-09-04 23:37:59
$ systemctl is-active nginx
active
$ ss -ltnp | grep :80
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",pid=1978,fd=6),("nginx",pid=1976,fd=6),("nginx",pid=1975,fd=6))
LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=1978,fd=7),("nginx",pid=1976,fd=7),("nginx",pid=1975,fd=7))
$ pgrep -af hog.py || echo NOHOG
NOHOG
障害注入から復旧完了までは5分29秒でした。
| 時刻 | イベント |
|---|---|
| 08:33:14 | Nginx を停止してポート80を占有 |
| 08:36:00 | アプリケーションステータスが impaired(ResponseTimeout) |
| 08:37:19 | CloudWatch アラームが OK から ALARM へ |
| 08:37:20 | Step Functions の実行開始 |
| 08:37:35 | SSM の再起動コマンドが Failed になり Stage2Stop へ分岐 |
| 08:37:51 | 停止を確認して起動 |
| 08:38:43 | NotifyStage2OK で終了(実行時間83秒) |
1段階目で終わった実行との差は、Stop/Start とその完了待ちの分だけです。
インスタンス置き換えへの追随
タグで関連付けた場合、インスタンスが置き換わっても監視が続くかを確認しました。EC2 インスタンスの論理 ID を変更して update-stack を実行すると、インスタンスが置き換わります。関連付けの API は実行し直していません。
| 項目 | 結果 |
|---|---|
| 置き換え前のインスタンス | terminated |
| 置き換え後のインスタンス | running |
| 関連付け | tag(aws:cloudformation:stack-id)の1件のみ |
| 置き換え直後の新インスタンスのアプリケーションステータス | initializing(その後 ok) |
置き換え後のインスタンスのアプリケーションステータスです。
{
"ApplicationStatuses": {
"Instances": [
{
"InstanceId": "i-xxxxxxxxxxxxxxxxx2",
"AvailabilityZone": "apne1-az2",
"ApplicationStatus": {
"Status": "ok",
"StatusTimeStamp": "2026-09-05T08:00:29.426000+00:00",
"StatusSince": "2026-09-05T08:01:00+00:00",
"Details": [
{
"ApplicationStatusCheckId": "asc-xxxxxxxx",
"CheckUpdateTime": "2026-09-05T08:00:29.426000+00:00",
"Aggregation": "included",
"Status": "passed",
"StatusTimeStamp": "2026-09-05T08:00:29.426000+00:00",
"StatusSince": "2026-09-05T08:01:00+00:00",
"Reason": {
"Code": "ResponseCodeMatched",
"StatusCode": 200,
"Protocol": "HTTP"
}
}
]
}
}
]
}
}
関連付けの操作なしで監視が続きました。
維持費
課金対象はマネージド ENI で、単価はアベイラビリティゾーンごとに1時間あたり 0.01 USD です。ユーザーガイドの Pricing 節には次のように記載されています。
An hourly charge of $0.01 for each managed elastic network interface (ENI), per Availability Zone.
Standard Amazon CloudWatch pricing applies to application status check metrics.
出典は EC2 ユーザーガイドの Application status checks(2026-09-05 参照)です。マネージド ENI は「送信元サブネット × セキュリティグループ」の組み合わせごとに1つ作られ、インスタンス台数には比例しません。ユーザーガイドは、200台を2サブネット・1セキュリティグループに収めれば ENI は2つ、同じ200台で3つのセキュリティグループを使うと最大6つになると説明しています。ENI 1つなら 730時間で月額 7.30 USD です。
この構成を動かし続けている間に料金がかかるのは、マネージド ENI とアラーム、それに障害が起きたときの Step Functions の状態遷移です。ヘルスチェックは EC2 の機能が実行するので、正常時に動くものはありません。
| 項目 | 単価 | 無料枠 |
|---|---|---|
| マネージド ENI | 0.01 USD / 時間 / ENI | なし |
| Step Functions Standard の状態遷移 | 0.000025 USD / 遷移 | 月4,000遷移 |
| CloudWatch メトリクスアラーム | 0.10 USD / アラームメトリクス月 | 月10アラームメトリクス |
| CloudWatch Logs の Vended Logs 取り込み(CloudWatch Logs 宛先) | 0.50 USD / GB(0〜10TB) | 月5GB |
状態遷移とログの単価は、AWS Step Functions の料金ページと Amazon CloudWatch の料金ページに示された計算例の値です。計算例はいずれも米国東部のもので、2026-09-05 に参照しました。無料利用枠の状態遷移は月4,000回で、AWS 無料利用枠の12か月が終わっても期限切れになりません。
ENI 1つの構成なら月額 7.30 USD が固定でかかり、アラームは無料枠が残っていれば 0 USD です。復旧が月に数回起きても、状態遷移数は1実行あたり15〜23回なので無料枠に収まります。監視するインスタンスを増やしても ENI の料金は変わりませんが、アラームをインスタンスごとに作ればその分は増えます。
ヘルスチェックを Step Functions の定期実行で自作すると、課金は状態遷移の回数で決まります。1実行を5遷移とすると、1分間隔で月216,000遷移の 5.30 USD、10分間隔で月21,600遷移の 0.44 USD です。間隔を空ければ費用は抑えられますが、定期実行と復旧の実行が重なったときの多重実行対策が必要になり、実行履歴にも正常時の記録が混ざります。ヘルスチェックの仕組みはアプリケーションステータスチェックに任せる判断にしました。
ロググループは Level: ALL と IncludeExecutionData: true で出力しました。
| 実行 | 実行されたステート数 | ログイベント数 | 取り込みバイト数 |
|---|---|---|---|
| Nginx が既に復旧していた実行 | 8 | 23 | 27,090 |
| Stage 1 で復旧した実行 | 15 | 53 | 58,294 |
| Stage 2 で復旧した実行 | 23 | 81 | 122,171 |
実行履歴とロググループに残るのは復旧イベントだけなので、この規模では Vended Logs の無料枠に収まります。
撤去
撤去時は、最初にタグによる関連付けを解除してからスタックを削除します。
aws ec2 disassociate-application-status-check \
--application-status-check-id $ASC_ID \
--target-tag-associations Key=aws:cloudformation:stack-id,Value=$STACK_ID \
--region ap-northeast-1
aws cloudformation delete-stack \
--stack-name ec2-app-recovery-test \
--region ap-northeast-1
EC2 インスタンス、アプリケーションステータスチェック、ステートマシンは削除できましたが、セキュリティグループの削除は DependencyViolation になりました。マネージド ENI がセキュリティグループを使用しているためです。
An error occurred (DependencyViolation) when calling the DeleteSecurityGroup operation:
resource sg-xxxxxxxxxxxxxxxxx has a dependent object
アプリケーションステータスチェックを削除しても、マネージド ENI の削除は非同期で、消えるまでには時間がかかります。今回はスタックの削除操作から11分後に再試行しても DependencyViolation のままで、スタックは DELETE_FAILED になりました。約4時間後にセキュリティグループを削除でき、その後に delete-stack を実行してスタックの削除が完了しました。
DELETE_FAILED になったスタックを先に消したい場合は、セキュリティグループを保持したまま削除します。ENI が消えた後に、セキュリティグループを手動で削除します。
aws cloudformation delete-stack \
--stack-name ec2-app-recovery-test \
--retain-resources AppStatusCheckSourceSG \
--region ap-northeast-1
まとめ
多段の復旧シナリオを Step Functions で組めました。1段階目で SSM による Nginx の再起動を試し、それで戻らなかったときだけ EC2 の Stop/Start へ進みます。アプリが応答しているかの判定は EC2 の機能に任せたので、ヘルスチェックの実装も Lambda 関数も書いていません。
SSM を組み合わせれば、復旧そのもの以外の手順も同じステートマシンに載せられます。アラートを受けてからの追加調査、取得した情報にもとづく障害の切り分け、アプリケーションの再起動までを自動化の対象にできます。
EC2 Auto Scaling でインスタンスを置き換える復旧が取りにくいステートフルな環境で、アプリケーションの障害をサービスの再起動で復旧している場合は、自動化の手段の一つとして試してみてください。
参考資料
デプロイ
CloudFormation のデプロイモードには EXPRESS を指定しました。
aws cloudformation create-stack \
--stack-name ec2-app-recovery-test \
--template-body file://ec2-app-recovery-test.yaml \
--parameters \
ParameterKey=NotificationEmail,ParameterValue=alerts@example.com \
ParameterKey=VpcId,ParameterValue=vpc-xxxxxxxxxxxxxxxxx \
ParameterKey=SubnetId,ParameterValue=subnet-xxxxxxxxxxxxxxxxx \
--capabilities CAPABILITY_IAM \
--deployment-config '{"Mode": "EXPRESS"}' \
--region ap-northeast-1
aws cloudformation wait stack-create-complete \
--stack-name ec2-app-recovery-test \
--region ap-northeast-1
テンプレート全文
CloudFormation テンプレート全文
AWSTemplateFormatVersion: '2010-09-09'
Description: 'EC2 Nginx + Application Status Check + Step Functions 2-Stage Recovery'
Parameters:
NotificationEmail:
Type: String
InstanceType:
Type: String
Default: t4g.nano
LatestAmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
LogRetentionDays:
Type: Number
Default: 7
VpcId:
Type: AWS::EC2::VPC::Id
Description: VPC ID
SubnetId:
Type: AWS::EC2::Subnet::Id
Description: Subnet ID for EC2 and Application Status Check
Resources:
EC2SecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: !Sub ${AWS::StackName} - EC2 SG
VpcId: !Ref VpcId
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-ec2-sg
AppStatusCheckSourceSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: !Sub ${AWS::StackName} - Source SG for the application status check ENI
VpcId: !Ref VpcId
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-asc-sg
EC2SecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !Ref EC2SecurityGroup
IpProtocol: tcp
FromPort: 80
ToPort: 80
SourceSecurityGroupId: !GetAtt AppStatusCheckSourceSG.GroupId
Description: Application status check from the managed ENI
EC2Role:
Type: AWS::IAM::Role
Properties:
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:
Roles:
- !Ref EC2Role
NginxEC2Instance:
Type: AWS::EC2::Instance
Properties:
InstanceType: !Ref InstanceType
ImageId: !Ref LatestAmiId
IamInstanceProfile: !Ref EC2InstanceProfile
SubnetId: !Ref SubnetId
SecurityGroupIds:
- !Ref EC2SecurityGroup
UserData:
Fn::Base64: |
#!/bin/bash
dnf install -y nginx
echo ok > /usr/share/nginx/html/health
systemctl enable nginx
systemctl start nginx
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-nginx
ApplicationStatusCheck:
Type: AWS::EC2::ApplicationStatusCheck
Properties:
Protocol: http
Port: 80
Path: /
StatusCodeMatcher: '200'
InitializationGracePeriodSeconds: 60
HealthCheckPaths:
- Source:
SecurityGroupId: !GetAtt AppStatusCheckSourceSG.GroupId
SubnetId: !Ref SubnetId
Destinations:
- SecurityGroupId: !GetAtt EC2SecurityGroup.GroupId
SubnetId: !Ref SubnetId
Tags:
- Key: Name
Value: !Sub ${AWS::StackName}-asc
AppStatusCheckAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: !Sub ${AWS::StackName}-StatusCheckFailed-App
Namespace: AWS/EC2
MetricName: StatusCheckFailed_Application
Dimensions:
- Name: InstanceId
Value: !Ref NginxEC2Instance
Statistic: Maximum
Period: 60
EvaluationPeriods: 2
Threshold: 1
ComparisonOperator: GreaterThanOrEqualToThreshold
TreatMissingData: missing
AlertTopic:
Type: AWS::SNS::Topic
EmailSubscription:
Type: AWS::SNS::Subscription
Properties:
TopicArn: !Ref AlertTopic
Protocol: email
Endpoint: !Ref NotificationEmail
EventBridgeRole:
Type: AWS::IAM::Role
Properties:
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
RecoveryTriggerRule:
Type: AWS::Events::Rule
Properties:
EventPattern:
source:
- aws.cloudwatch
detail-type:
- CloudWatch Alarm State Change
detail:
alarmName:
- !Ref AppStatusCheckAlarm
state:
value:
- ALARM
State: ENABLED
Targets:
- Arn: !GetAtt RecoveryStateMachine.Arn
RoleArn: !GetAtt EventBridgeRole.Arn
Id: RecoveryStateMachineTarget
StepFunctionsRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: states.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: EC2AndSSM
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- ec2:StopInstances
- ec2:StartInstances
Resource: !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${NginxEC2Instance}
- Effect: Allow
Action:
- ec2:DescribeInstances
Resource: '*'
- Effect: Allow
Action: ssm:SendCommand
Resource:
- !Sub arn:aws:ssm:${AWS::Region}::document/AWS-RunShellScript
- !Sub arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/${NginxEC2Instance}
- Effect: Allow
Action: ssm:GetCommandInvocation
Resource: '*'
- 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: '*'
StepFunctionsLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub /aws/vendedlogs/states/${AWS::StackName}
RetentionInDays: !Ref LogRetentionDays
RecoveryStateMachine:
Type: AWS::StepFunctions::StateMachine
Properties:
StateMachineType: STANDARD
RoleArn: !GetAtt StepFunctionsRole.Arn
LoggingConfiguration:
Level: ALL
IncludeExecutionData: true
Destinations:
- CloudWatchLogsLogGroup:
LogGroupArn: !GetAtt StepFunctionsLogGroup.Arn
DefinitionSubstitutions:
InstanceId: !Ref NginxEC2Instance
SnsTopicArn: !Ref AlertTopic
DefinitionString: |
{
"Comment": "2-Stage Recovery: SSM restart -> EC2 Stop/Start",
"QueryLanguage": "JSONata",
"TimeoutSeconds": 1800,
"StartAt": "Init",
"States": {
"Init": {
"Type": "Pass",
"Assign": {
"stage1Retry": 0,
"stopCheck": 0,
"startCheck": 0,
"recoveryCheck": 0,
"forceStop": false
},
"Next": "CheckNginxInit"
},
"CheckNginxInit": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:sendCommand",
"Arguments": {
"InstanceIds": ["${InstanceId}"],
"DocumentName": "AWS-RunShellScript",
"Parameters": {"commands": ["systemctl is-active nginx"]},
"TimeoutSeconds": 30
},
"Assign": {"checkCmdId": "{% $states.result.Command.CommandId %}"},
"Next": "WaitCheckInit",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "SSMRestart"}]
},
"WaitCheckInit": {
"Type": "Wait",
"Seconds": 5,
"Next": "GetCheckInitResult"
},
"GetCheckInitResult": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:getCommandInvocation",
"Arguments": {"CommandId": "{% $checkCmdId %}", "InstanceId": "${InstanceId}"},
"Assign": {
"nginxStatus": "{% $states.result.StandardOutputContent %}",
"checkExitCode": "{% $states.result.ResponseCode %}"
},
"Next": "EvalInitStatus",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "SSMRestart"}]
},
"EvalInitStatus": {
"Type": "Choice",
"Choices": [{"Condition": "{% $checkExitCode = 0 %}", "Next": "NotifyAlreadyOK"}],
"Default": "SSMRestart"
},
"SSMRestart": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:sendCommand",
"Arguments": {
"InstanceIds": ["${InstanceId}"],
"DocumentName": "AWS-RunShellScript",
"Parameters": {"commands": ["sudo systemctl restart nginx"]},
"TimeoutSeconds": 60
},
"Assign": {"cmdId": "{% $states.result.Command.CommandId %}"},
"Next": "WaitSSM",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "Stage2Stop"}]
},
"WaitSSM": {
"Type": "Wait",
"Seconds": 10,
"Next": "CheckSSM"
},
"CheckSSM": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:getCommandInvocation",
"Arguments": {"CommandId": "{% $cmdId %}", "InstanceId": "${InstanceId}"},
"Assign": {"ssmStatus": "{% $states.result.Status %}"},
"Next": "EvalSSM",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "Stage2Stop"}]
},
"EvalSSM": {
"Type": "Choice",
"Choices": [
{"Condition": "{% $ssmStatus = 'Success' %}", "Next": "WaitAfterRestart"},
{"Condition": "{% $ssmStatus = 'Failed' or $ssmStatus = 'TimedOut' %}", "Next": "Stage2Stop"}
],
"Default": "WaitSSM"
},
"WaitAfterRestart": {
"Type": "Wait",
"Seconds": 10,
"Next": "CheckNginxAfterRestart"
},
"CheckNginxAfterRestart": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:sendCommand",
"Arguments": {
"InstanceIds": ["${InstanceId}"],
"DocumentName": "AWS-RunShellScript",
"Parameters": {"commands": ["systemctl is-active nginx"]},
"TimeoutSeconds": 30
},
"Assign": {"checkCmdId": "{% $states.result.Command.CommandId %}"},
"Next": "WaitCheckAfterRestart",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "Stage2Stop"}]
},
"WaitCheckAfterRestart": {
"Type": "Wait",
"Seconds": 5,
"Next": "GetCheckAfterRestartResult"
},
"GetCheckAfterRestartResult": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:getCommandInvocation",
"Arguments": {"CommandId": "{% $checkCmdId %}", "InstanceId": "${InstanceId}"},
"Assign": {
"checkExitCode": "{% $states.result.ResponseCode %}",
"stage1Retry": "{% $stage1Retry + 1 %}"
},
"Next": "EvalStage1",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "Stage2Stop"}]
},
"EvalStage1": {
"Type": "Choice",
"Choices": [
{"Condition": "{% $checkExitCode = 0 %}", "Next": "NotifyStage1OK"},
{"Condition": "{% $stage1Retry >= 3 %}", "Next": "Stage2Stop"}
],
"Default": "WaitAfterRestart"
},
"Stage2Stop": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
"Arguments": {"InstanceIds": ["${InstanceId}"], "Force": false},
"Assign": {"stopCheck": 0},
"Next": "WaitStop",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "NotifyError"}]
},
"ForceStop": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
"Arguments": {"InstanceIds": ["${InstanceId}"], "Force": true},
"Assign": {"forceStop": true, "stopCheck": 0},
"Next": "WaitStop",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "NotifyError"}]
},
"WaitStop": {
"Type": "Wait",
"Seconds": 15,
"Next": "CheckStop"
},
"CheckStop": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
"Arguments": {"InstanceIds": ["${InstanceId}"]},
"Assign": {
"ec2State": "{% $states.result.Reservations[0].Instances[0].State.Name %}",
"stopCheck": "{% $stopCheck + 1 %}"
},
"Next": "EvalStop",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "NotifyError"}]
},
"EvalStop": {
"Type": "Choice",
"Choices": [
{"Condition": "{% $ec2State = 'stopped' %}", "Next": "StartInstance"},
{"Condition": "{% $stopCheck >= 40 and $forceStop = false %}", "Next": "ForceStop"},
{"Condition": "{% $stopCheck >= 40 and $forceStop = true %}", "Next": "NotifyStopFail"}
],
"Default": "WaitStop"
},
"StartInstance": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
"Arguments": {"InstanceIds": ["${InstanceId}"]},
"Assign": {"startCheck": 0},
"Next": "WaitStart",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "NotifyError"}]
},
"WaitStart": {
"Type": "Wait",
"Seconds": 15,
"Next": "CheckStart"
},
"CheckStart": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
"Arguments": {"InstanceIds": ["${InstanceId}"]},
"Assign": {
"ec2State": "{% $states.result.Reservations[0].Instances[0].State.Name %}",
"startCheck": "{% $startCheck + 1 %}"
},
"Next": "EvalStart",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "NotifyError"}]
},
"EvalStart": {
"Type": "Choice",
"Choices": [
{"Condition": "{% $ec2State = 'running' %}", "Next": "WaitRecovery"},
{"Condition": "{% $startCheck >= 20 %}", "Next": "NotifyFail"}
],
"Default": "WaitStart"
},
"WaitRecovery": {
"Type": "Wait",
"Seconds": 30,
"Next": "CheckNginxRecovery"
},
"CheckNginxRecovery": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:sendCommand",
"Arguments": {
"InstanceIds": ["${InstanceId}"],
"DocumentName": "AWS-RunShellScript",
"Parameters": {"commands": ["systemctl is-active nginx"]},
"TimeoutSeconds": 30
},
"Assign": {"checkCmdId": "{% $states.result.Command.CommandId %}"},
"Next": "WaitCheckRecovery",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "RetryRecoveryCheck"}]
},
"WaitCheckRecovery": {
"Type": "Wait",
"Seconds": 5,
"Next": "GetCheckRecoveryResult"
},
"GetCheckRecoveryResult": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:ssm:getCommandInvocation",
"Arguments": {"CommandId": "{% $checkCmdId %}", "InstanceId": "${InstanceId}"},
"Assign": {
"checkExitCode": "{% $states.result.ResponseCode %}",
"recoveryCheck": "{% $recoveryCheck + 1 %}"
},
"Next": "EvalRecovery",
"Catch": [{"ErrorEquals": ["States.ALL"], "Next": "RetryRecoveryCheck"}]
},
"RetryRecoveryCheck": {
"Type": "Pass",
"Assign": {"recoveryCheck": "{% $recoveryCheck + 1 %}"},
"Next": "EvalRecoveryRetry"
},
"EvalRecoveryRetry": {
"Type": "Choice",
"Choices": [{"Condition": "{% $recoveryCheck >= 10 %}", "Next": "NotifyFail"}],
"Default": "WaitRecovery"
},
"EvalRecovery": {
"Type": "Choice",
"Choices": [
{"Condition": "{% $checkExitCode = 0 %}", "Next": "NotifyStage2OK"},
{"Condition": "{% $recoveryCheck >= 10 %}", "Next": "NotifyFail"}
],
"Default": "WaitRecovery"
},
"NotifyStage1OK": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${SnsTopicArn}",
"Subject": "[OK] Nginx復旧 (SSM restart)",
"Message": "{% 'SSM restart で復旧しました。\\nInstanceId: ${InstanceId}\\nRetry: ' & $string($stage1Retry) & '\\nExecutionId: ' & $states.context.Execution.Id %}"
},
"End": true
},
"NotifyStage2OK": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${SnsTopicArn}",
"Subject": "[OK] EC2復旧 (Stop/Start)",
"Message": "{% 'EC2 Stop/Start で復旧しました。\\nInstanceId: ${InstanceId}\\nRecoveryCheck: ' & $string($recoveryCheck) & '\\nExecutionId: ' & $states.context.Execution.Id %}"
},
"End": true
},
"NotifyAlreadyOK": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${SnsTopicArn}",
"Subject": "[INFO] 自然復旧",
"Message": "{% 'Nginxは既に正常です。\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id %}"
},
"End": true
},
"NotifyFail": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${SnsTopicArn}",
"Subject": "[CRITICAL] 復旧失敗",
"Message": "{% '自動復旧に失敗しました。手動確認が必要です。\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id %}"
},
"End": true
},
"NotifyStopFail": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${SnsTopicArn}",
"Subject": "[CRITICAL] EC2停止失敗",
"Message": "{% 'EC2の停止に失敗しました。\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id %}"
},
"End": true
},
"NotifyError": {
"Type": "Task",
"Resource": "arn:aws:states:::aws-sdk:sns:publish",
"Arguments": {
"TopicArn": "${SnsTopicArn}",
"Subject": "[ERROR] 予期せぬエラー",
"Message": "{% 'エラーが発生しました。\\nInstanceId: ${InstanceId}\\nExecutionId: ' & $states.context.Execution.Id %}"
},
"End": true
}
}
}
Outputs:
InstanceId:
Value: !Ref NginxEC2Instance
ApplicationStatusCheckId:
Value: !GetAtt ApplicationStatusCheck.ApplicationStatusCheckId
AppStatusCheckSourceSGId:
Value: !Ref AppStatusCheckSourceSG
SNSTopicArn:
Value: !Ref AlertTopic
StateMachineArn:
Value: !GetAtt RecoveryStateMachine.Arn
StepFunctionsLogGroupName:
Value: !Ref StepFunctionsLogGroup







