EC2 アプリケーションステータスチェックと Step Functions で Nginx の2段階自動復旧を試してみた

EC2 アプリケーションステータスチェックと Step Functions で Nginx の2段階自動復旧を試してみた

アラート発生時、1段階目は SSM でサービスを再起動し、それで戻らなければ2段階目で EC2 を Stop/Start します。インスタンスの置き換えでは復旧しにくいステートフルな環境を想定し、Lambda は使わず AWS SDK 統合と JSONata だけで組みました。
2026.09.05

はじめに

2026年8月、EC2 にアプリケーションステータスチェックが提供開始されました。VPC 内から監視対象のインスタンスへ HTTP リクエストを送り、その応答でアプリ層の状態を判定する機能です。

https://aws.amazon.com/jp/about-aws/whats-new/2026/08/amazon-ec2-application-status-checks/

https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/application-status-checks.html

先行記事では、Instance Status Check の失敗を受けて Step Functions で EC2 を Stop/Start する構成を扱いました。

https://dev.classmethod.jp/articles/ec2-auto-recovery-stepfunctions-lambdaless/

本記事では、Nginx の HTTP 応答をこのチェックに任せ、異常を検知したら Step Functions が2段階で復旧する仕組みを作ります。1段階目は SSM による Nginx の再起動、2段階目は EC2 の Stop/Start です。Lambda 関数は使わず、AWS SDK 統合と JSONata だけで組みました。

検証内容

構成、監視が始まるまでの経過、Nginx を停止した場合、再起動できない場合の順に説明します。

構成

アプリケーションステータスチェックは、2026年8月にリリースされた機能です。チェックの作成からステータス遷移、マネージド ENI の仕組みまでは別記事で検証しています。

https://dev.classmethod.jp/articles/amazon-ec2-application-status-checks/

監視対象のインスタンスへ HTTP GET を送り、チェックが失敗すると、CloudWatch メトリクスの StatusCheckFailed_Application に記録されます。CloudWatch アラームと EventBridge を経由して、Step Functions の実行が始まります。ステートマシンは最初に SSM で Nginx を再起動し、失敗した場合だけ EC2 を Stop/Start して、結果を SNS へ通知します。

アプリケーションステータスチェックのマネージド ENI が VPC 内から EC2 へ HTTP GET し、StatusCheckFailed_Application から CloudWatch アラーム、EventBridge、Step Functions へつながる構成図。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 を置くサブネットとセキュリティグループ、宛先には監視するインスタンスのサブネットとセキュリティグループを指定します。SourceDestinations の両方でサブネットの指定が必要です。

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段階目のステートだけでした。

Step Functions の実行グラフ。Init から SSMRestart を経て NotifyStage1OK までのステートが緑で表示され、Stage2Stop 以降は通っていない

障害注入から復旧完了までは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: ALLIncludeExecutionData: 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

この記事をシェアする

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

関連記事