Blog

CloudWatch AlarmをトリガーにEC2で任意のコマンドを実行する話

山口 朔也 技術ブログ

みなさんこんにちは。お元気ですか。自称プログラマの山口です。
暖かくなってきてビールがどんどんおいしくなってきましたね。

この記事では、CloudWatch Alarmの発報を条件にEC2内で任意のコマンドを実行する方法を説明します。  
これにより、たとえば「CPU使用率が80%を超えた時、 $ ps aux --sort -%cpu の結果をファイルに保存する」といったことができます。

動機

CloudWatch Alarmの発報があった時、多くのサービスではAPMなどがインストールされており、原因の調査が簡単です。
一方、サービスの保守費用を減らす構成の場合、一台のEC2上にさまざまなアプリケーション(PHP, MySQL, nginx, その他バッチ処理)を詰め込んでおり、APMでは原因が特定できないことが多いです。
さらに、アラートの多くは深夜に発報することが多く(体感)、翌日になると調査が困難です。

そこで、アラートの発報を条件に ps コマンドなどを実行することで、その時のプロセスの一覧を取得し調査できるようにします。

設定の方法

1. 設定の概要

すでに以下のような構成があるとします。

  • EC2: IAMロールとして、ポリシー `AmazonSSMManagedInstanceCore` が設定済みとします。
  • CloudWatch Alarm: CPU使用率に関するアラートが設定済みとします。

以下のリソースを追加することで、CloudWatch Alarmをトリガーにコマンドを実行します。

  • SNSトピック
  • Lambda
  • AWS Systems Manager Run Command

2. SNSトピックの作成とCloudWatch Alarmの設定

CloudWatch AlarmからLambda関数を呼び出すには、SNSトピックを経由する必要があります。

  1. SNSトピックを作成します。
  2. CloudWatch Alarmの設定で、アラーム状態になったときに作成したSNSトピックに通知するように設定します。
  3. 作成したSNSトピックをLambda関数のトリガーとして追加します。

3. Lambda関数の登録

今回はPythonで実装します。このコードはおそらく機能します。

import json
import boto3

ssm = boto3.client('ssm')

def lambda_handler(event, context):
    try:
        message = json.loads(event['Records'][0]['Sns']['Message'])
    except (KeyError, json.JSONDecodeError) as e:
        print(f"Error parsing SNS message: {e}")
        return {"status": "error", "reason": "failed to parse message"}

    alarm_name = message.get('AlarmName')
    trigger = message.get('Trigger', {})
    metric_name = trigger.get('MetricName')
    namespace = trigger.get('Namespace')

    # メトリック名を元にフィルタリングします。
    # CPUUtilizationかつEC2以外の場合は何もしません。
    if metric_name != "CPUUtilization" or namespace != "AWS/EC2":
        print(f"Skipping alarm '{alarm_name}' (metric={metric_name}, namespace={namespace})")
        return {"status": "skipped", "alarm": alarm_name}

    instance_id = None
    for d in trigger.get('Dimensions', []):
        # DimensionsはName/Value(大文字)またはname/value(小文字)の形式に対応
        if d.get('Name') == "InstanceId" or d.get('name') == "InstanceId":
            instance_id = d.get('Value') or d.get('value')
            break

    if not instance_id:
        print(f"No InstanceId found in alarm '{alarm_name}'")
        return {"status": "skipped", "reason": "no instance id"}

    print(f"Executing 'ps aux --sort -%cpu' on instance: {instance_id} (alarm: {alarm_name})")

    try:
        response = ssm.send_command(
            InstanceIds=[instance_id],
            DocumentName="AWS-RunShellScript",
            Parameters={"commands": ["ps aux --sort -%cpu > /var/log/cpuusage_$(date +%Y%m%d-%H%M%S).log"]},
            CloudWatchOutputConfig={"CloudWatchOutputEnabled": True}
        )
        print(f"Command sent successfully. CommandId: {response.get('Command', {}).get('CommandId')}")
        return {"status": "sent", "instance": instance_id, "alarm": alarm_name, "command_id": response.get('Command', {}).get('CommandId')}
    except Exception as e:
        print(f"Error sending SSM command: {e}")
        return {"status": "error", "reason": str(e), "instance": instance_id}

今回は結果を /var/log/ 以下に保存します。

注意: /var/log/ に書き込むには適切な権限が必要です。SSM Run Commandはデフォルトで root または ec2-user として実行されるため通常は問題ありませんが、環境によっては権限の調整が必要な場合があります。

4. LambdaにIAMロールを登録

上記のLambda関数に、以下のポリシーを許可するロールを登録します。

  • ssm:SendCommand

IAMポリシーのJSON例は以下の通りです。

{
  "Version": "2012-10-17",
  "Statement": [     {       "Effect": "Allow",       "Action": [         "ssm:SendCommand"       ],       "Resource": "*"     }   ] }

5. 動作確認

動作確認のため、 stress コマンドを実行します。

# 無い場合はインストールします。
$ sudo dnf install stress
$ stress --cpu 2 --timeout 60

指定したディレクトリ /var/log/ 以下にログファイルが出力されていることを確認できたらOKです。

注意: stressコマンドを実行するとCPU負荷が上昇します。開発環境など障害が発生しても問題が無い環境で試してください。

さいごに

今回は、インフラ費用を抑えた構成のCPU使用率上昇時のログ調査を記載しました。  
さくらのクラウド + Zabbix といった構成にすれば、さらにインフラ費用を抑えることができるので、次回はそれをテーマにしてみようと思います。