CloudWatch Logs 収集対象のログ監視をしてアラームが上がってきた場合、SNS で通知することは可能ですが、エラーログの内容までは通知されません。そこで、SNS からの通知を基に、エラーログの内容を取得し、更に、その内容を Slack に投稿するようにしてみました。Lambda を使って Go で実装しました。
目次
前提
- 以前の記事同様、Go の開発環境が整っている必要があります。
- Go で実装した Lambda を使って Slack Bot を作る方法は下記の記事をご覧ください。
今回作成するものの構成
- 今回は、Lambda のエラーログ監視をし、エラーがあった場合に Slack に通知するものとします。
- 処理の流れとしては上図のとおりです。
サービスの概要
CloudWatch
- 公式サイト
- CloudWatch は AWS リソースと、AWS で実行されているアプリをリアルタイムでモニタリングするマネージドサービスです。
- 大きく分けて 3 つの機能があります。
- CloudWatch
- 基本となる監視サービスです。
- メトリクス という時系列のデータポイントのセットを基に監視したりグラフを作成したりできます。
- CPU やメモリの使用量、利用料金など、標準で用意されているメトリクスがあります。
- ユーザーでメトリクスを定義することもできます。
- リソースの状況に応じて、メール送信や再起動、オートスケーリングをする設定ができます。
- CloudWatch Logs
- AWS の各種サービスのログの監視、保管、アクセスができます。
- モニタリングされているアプリやリソースのアクティビティは、ログイベントに記録されます。
- メトリクスフィルター を定義することで、ログイベントから特定の文字列のフィルタリングが可能です。
- CloudWatch Events
- CloudWatch
- 今回は、CloudWatch Logs でメトリクスフィルターを定義し、ログの監視を行います。
Simple Notification System(SNS)
- 公式サイト
- サーバーレスなアプリケーションで通知を実装できるサービスです。
- 通知プロトコルとしては、HTTP、HTTPS、Eメール、Amazon SQS、AWS Lambda、SMS(Short Message Service)が利用可能です。
- Pub/Sub 型メッセージングモデルを採用しています。
- メッセージを送信するシステム、AWS のサービス(Publisher)からトピックにメッセージが送信され、通知を受け取るクライアント(Subscriber)はトピックを購読します。
- 今回は、CloudWatch Logs のアラームの送信先を SNS のトピックに設定し、Lambda でトピックの購読をします。
実装手順
- 例によって Serverless Framework を用いて Go のプロジェクトのテンプレートを作成して利用します。
1. 監視対象のアプリケーションを実装
- まずは、監視対象となるアプリケーションを実装します。
- 今回は、Lambda で単純にエラーログを出力するだけのものを用意しておきます。
package main import ( "log" "github.com/aws/aws-lambda-go/lambda" ) // Handler is our lambda handler invoked by the `lambda.Start` function call func Handler() { log.Print("ERROR: これはエラーログです") } func main() { lambda.Start(Handler) }
2. CloudWatch Logs のメトリクスフィルター定義
- で作成したアプリケーションのログの監視設定を行います。
- AWS コンソールから CloudWatch を開き、左メニュー「ログ」→「ロググループ」から監視対象のアプリケーションを探します。
- メトリクスフィルターの一覧を開き、「メトリクスフィルターの作成」を選択
- フィルタリングする文字列を「フィルターパターン」に入力します。
- 今回は「ERROR」とします。
- メトリクスフィルターのメトリックスの割当設定を行います。
- 「メトリクス名前空間」は、ログの監視であることがわかるように名前をつけておきます。
- 「メトリクス名」と「フィルター名」は同じで良いでしょう。
- 「メトリクス値」は 1 とします。これにより、「ERROR」を含むログイベントのカウント増分が 1 となります。
- 設定を確認し、「メトリクスフィルターを作成」で作成を完了します。
3. SNS のトピックの作成とアラームの定義
- アラームが発生した場合の通知の送り先となる SNS トピックを作成します。AWS コンソールから「Simple Notification Service」を選択します。
- 左メニュー「トピック」を開き、「トピックの作成」を選択します。
- トピック名と表示名を決め、「トピックの作成」をクリックします。
- 作成後、トピックの ARN は控えておきます。
- アラームの設定を行います。当該のロググループのメトリクスフィルター一覧からメトリクスフィルターにチェックを入れた状態で「アラームを作成」を選択します。
- メトリクスの条件を指定します。
- 今回は、「1 分間に『ERROR』を含むログ出力が 1 回でも発生する」という事象が発生した場合にアラームを通知するように設定します。
- 通知アクションの設定をします。「通知の送信先」として、先ほど設定したトピック名を入力します。
- 最後にアラーム名を入力してアラーム作成を完了します。
- 実際にアラームが作動するかを確かめます。何もしていない状態では、「アラーム」は0件になっています。
- 当該アプリケーションを動作させ、エラーログを出力させると、アラームが発生します。
4. エラーログ取得用の Lambda を実装
- 次に、SNS の通知を基にエラーログを取得する Lambda を実装します。
- CloudWatchでエラーログの内容を通知させたい - Qiita の内容を参考に Go で実装します。
SNS から渡されるイベントの内容
- CloudWatch のアラームが SNS 経由で渡される情報は以下のようになっています。
{ "Records": [ { "EventSource": "aws:sns", "EventVersion": "1.0", "EventSubscriptionArn": "arn:aws:sns:us-east-2:{{{accountId}}}:ExampleTopic", "Sns": { "Type": "Notification", "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", "TopicArn": "arn:aws:sns:us-east-2:123456789012:ExampleTopic", "Subject": "example subject", "Message": " { "AlarmName": "sample-error", "AlarmDescription": "sampleでエラーが発生しました。", "AWSAccountId": "xxxxxxxxxxxx", "NewStateValue": "ALARM", "NewStateReason": "Threshold Crossed: 1 datapoint [2.0 (29/11/17 01:09:00)] was greater than or equal to the threshold (1.0).", "StateChangeTime": "2017-11-29T01:10:32.907+0000", "Region": "Asia Pacific (Tokyo)", "OldStateValue": "OK", "Trigger": { "MetricName": "sample-metric", "Namespace": "LogMetrics", "StatisticType": "Statistic", "Statistic": "SUM", "Unit": null, "Dimensions": [], "Period": 60, "EvaluationPeriods": 1, "ComparisonOperator": "GreaterThanOrEqualToThreshold", "Threshold": 1, "TreatMissingData": "- TreatMissingData: NonBreaching", "EvaluateLowSampleCountPercentile": "" } } ", "Timestamp": "1970-01-01T00:00:00.000Z", "SignatureVersion": "1", "Signature": "EXAMPLE", "SigningCertUrl": "EXAMPLE", "UnsubscribeUrl": "EXAMPLE", "MessageAttributes": { "Test": { "Type": "String", "Value": "TestString" }, "TestBinary": { "Type": "Binary", "Value": "TestBinary" } } } } ] }
- Message の中身を見ても、エラーログの内容は書かれていません。
- しかしながら、Trigger の中にアラームに紐づくメトリクスの情報:「MetricName」と「Namespace」が含まれています。これらを基に、欲しい情報を取得していきます。
- Lambda で SNS イベントの取得から Message の内容の取得までは以下のように実装できます。
- メッセージの内容の JSON に基づく構造体を用意し、パースします。
import ( "encoding/json" "log" "github.com/aws/aws-lambda-go/events" ) type CloudwatchAlertMessage struct { AlarmName string `json:"AlarmName"` AlarmDescription string `json:"AlarmDescription"` AWSAccountID string `json:"AWSAccountId"` NewStateValue string `json:"NewStateValue"` NewStateReason string `json:"NewStateReason"` StateChangeTime string `json:"StateChangeTime"` Region string `json:"Region"` OldStateValue string `json:"OldStateValue"` Trigger struct { MetricName string `json:"MetricName"` Namespace string `json:"Namespace"` StatisticType string `json:"StatisticType"` Statistic string `json:"Statistic"` Unit string `json:"Unit"` Dimensions []string `json:"Dimensions"` Period json.Number `json:"Period"` EvaluationPeriods json.Number `json:"EvaluationPeriods"` ComparisonOperator string `json:"ComparisonOperator"` Threshold json.Number `json:"Threshold"` TreatMissingData string `json:"TreatMissingData"` EvaluateLowSampleCountPercentile string `json:"EvaluateLowSampleCountPercentile"` } `json:"Trigger"` } func Handler(ctx context.Context, event events.SNSEvent) { snsRecord := event.Records[0].SNS var cloudwatchAlertMessage CloudwatchAlertMessage if err := json.Unmarshal(([]byte)(snsRecord.Message), &cloudwatchAlertMessage); err != nil { log.Fatalf("ERROR: %s", err) } }
メトリクスフィルタの取得
cloudwatch.DescribeMetricFiltersInput
で MetricName と Namespace を指定すれば、メトリクスフィルタを取得できます。
import ( "github.com/aws/aws-sdk-go/service/cloudwatchlogs" "github.com/aws/aws-sdk-go/aws/session" ) metricFilterInfo := &cloudwatchlogs.DescribeMetricFiltersInput{ MetricName: &cloudwatchAlertMessage.Trigger.MetricName, MetricNamespace: &cloudwatchAlertMessage.Trigger.Namespace, } sess := session.Must(session.NewSessionWithOptions(session.Options{ SharedConfigState: session.SharedConfigEnable, })) svc := cloudwatchlogs.New(sess) metricFiltersOutput, err := svc.DescribeMetricFilters(metricFilterInfo) if err != nil { log.Fatalf("ERROR: %s", err) }
- 上記の
metricFiltersOutput
は以下のようになります。
{ "metricFilters": [ { "filterName": "sample-filter", "filterPattern": "ERROR", "metricTransformations": [ { "metricName": "sample-metric", "metricNamespace": "LogMetrics", "metricValue": "1" } ], "creationTime": 1493029160596, "logGroupName": "sample-loggroup" } ], "ResponseMetadata": { "RequestId": "xxxxxxxxxxxxxxxxxxxxxxxxxxx", "HTTPStatusCode": 200, "HTTPHeaders": { "x-amzn-requestid": "xxxxxxxxxxxxxxxxxxxxxxxxxxx", "content-type": "application/x-amz-json-1.1", "content-length": "210", "date": "Wed, 29 Nov 2017 01:10:33 GMT" }, "RetryAttempts": 0 } }
- この中の
filterPattern
とlogGroupName
により、取得したいログを抽出できます。
開始時刻と終了時刻の設定
- ログデータの抽出のために、
filterPattern
とlogGroupName
の他に、対象となる期間を設定しておきます。- 指定しておかなければ、全期間のログが抽出対象となっていまいます。
- SNS のメッセージで送られてきた
StateChangeTime
を基準に対象期間を指定します。 - 開始時刻と終了時刻は、ミリ秒単位の UNIX 時間で指定せねばなりません。
- 今回は、開始時刻と終了時刻を以下のように設定します:
- 終了時刻:
StateChangeTime
の 1 分後 - 開始時刻:「終了時刻」の 5 分前
- 終了時刻:
import ( "time" ) stateChangeTime, err := time.Parse("2006-01-02T15:04:05.000+0000", cloudwatchAlertMessage.StateChangeTime) if err != nil { log.Fatal(err) } timeto := stateChangeTime.Add(time.Duration(1) * time.Minute) uTo := timeto.Unix() * 1000 timefrom := timeto.Add(time.Duration(TimeFromMin) * (-time.Minute)) uFrom := timefrom.Unix() * 1000
ログイベントの取得
- 以上の情報を使って、ログイベントを取得します。
- ログイベントの取得は
cloudwatchlogs.FilterLogEventsInput
を用いて行います。
- ログイベントの取得は
filterLogEventsInput := &cloudwatchlogs.FilterLogEventsInput{ LogGroupName: metricFiltersOutput.MetricFilters[0].LogGroupName, FilterPattern: metricFiltersOutput.MetricFilters[0].FilterPattern, StartTime: &uFrom, EndTime: &uTo, Limit: &OutputLimit, } responce, err := svc.FilterLogEvents(filterLogEventsInput) if err != nil { log.Fatalf("EROOR: %s", err) }
Slack の設定
- Slack App を作成し、チャンネルへの投稿権限を付与してワークスペースにインストールしておきます。
- Slack への投稿部分は以下のように実装します。
import ( "github.com/nlopes/slack" ) if len(responce.Events) == 0 { log.Print("ログデータなし") } else { api := slack.New(os.Getenv("SLACK_ACCESS_TOKEN")) for _, event := range responce.Events { log.Printf("Event: %s", event) message := fmt.Sprintf(` AlermName: %s, LogStreamName: %s, Message: %s `, cloudwatchAlertMessage.AlarmName, *event.LogStreamName, *event.Message) api.PostMessage(os.Getenv("SLACK_CHANNEL"), slack.MsgOptionText(message, false)) } }
serverless.yml
の設定
serverlesss.yml
には、ログ取得用の Lambda に関して、以下の設定を記載しておきます:
service: cloudwatch-alerm-to-slack frameworkVersion: '>=1.28.0 <2.0.0' provider: name: aws runtime: go1.x stage: dev region: us-east-2 iamRoleStatements: - Effect: "Allow" Action: - "logs:DescribeMetricFilters" - "logs:FilterLogEvents" Resource: "*" environment: SLACK_ACCESS_TOKEN: [Slack の Bot User OAuth Access Token] SLACK_CHANNEL: [投稿先の Slack のチャンネル名] package: exclude: - ./** include: - ./bin/** functions: subscriber: handler: bin/subscriber events: - sns: [SNS トピックの ARN]
最終的な main.go
- 以上、まとめると、Subscriber にあたる
main.go
は以下のように実装できます。
package main import ( "context" "encoding/json" "fmt" "log" "os" "time" "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatchlogs" "github.com/nlopes/slack" ) // TimeFromMin : 何分前まで抽出対象とするか const TimeFromMin = 5 // OutputLimit : ログ出力件数の上限 var OutputLimit = int64(5) // CloudwatchAlertMessage : SNS 経由で送られてきたメッセージのオブジェクト type CloudwatchAlertMessage struct { AlarmName string `json:"AlarmName"` AlarmDescription string `json:"AlarmDescription"` AWSAccountID string `json:"AWSAccountId"` NewStateValue string `json:"NewStateValue"` NewStateReason string `json:"NewStateReason"` StateChangeTime string `json:"StateChangeTime"` Region string `json:"Region"` OldStateValue string `json:"OldStateValue"` Trigger struct { MetricName string `json:"MetricName"` Namespace string `json:"Namespace"` StatisticType string `json:"StatisticType"` Statistic string `json:"Statistic"` Unit string `json:"Unit"` Dimensions []string `json:"Dimensions"` Period json.Number `json:"Period"` EvaluationPeriods json.Number `json:"EvaluationPeriods"` ComparisonOperator string `json:"ComparisonOperator"` Threshold json.Number `json:"Threshold"` TreatMissingData string `json:"TreatMissingData"` EvaluateLowSampleCountPercentile string `json:"EvaluateLowSampleCountPercentile"` } `json:"Trigger"` } // Handler is our lambda handler invoked by the `lambda.Start` function call func Handler(ctx context.Context, event events.SNSEvent) { snsRecord := event.Records[0].SNS var cloudwatchAlertMessage CloudwatchAlertMessage if err := json.Unmarshal(([]byte)(snsRecord.Message), &cloudwatchAlertMessage); err != nil { log.Fatalf("ERROR: %s", err) } metricFilterInfo := &cloudwatchlogs.DescribeMetricFiltersInput{ MetricName: &cloudwatchAlertMessage.Trigger.MetricName, MetricNamespace: &cloudwatchAlertMessage.Trigger.Namespace, } sess := session.Must(session.NewSessionWithOptions(session.Options{ SharedConfigState: session.SharedConfigEnable, })) svc := cloudwatchlogs.New(sess) metricFiltersOutput, err := svc.DescribeMetricFilters(metricFilterInfo) if err != nil { log.Fatalf("ERROR: %s", err) } log.Printf("MetricFilterOutput: %s", metricFiltersOutput.String()) stateChangeTime, err := time.Parse("2006-01-02T15:04:05.000+0000", cloudwatchAlertMessage.StateChangeTime) if err != nil { log.Fatal(err) } timeto := stateChangeTime.Add(time.Duration(1) * time.Minute) uTo := timeto.Unix() * 1000 timefrom := timeto.Add(time.Duration(TimeFromMin) * (-time.Minute)) uFrom := timefrom.Unix() * 1000 filterLogEventsInput := &cloudwatchlogs.FilterLogEventsInput{ LogGroupName: metricFiltersOutput.MetricFilters[0].LogGroupName, FilterPattern: metricFiltersOutput.MetricFilters[0].FilterPattern, StartTime: &uFrom, EndTime: &uTo, Limit: &OutputLimit, } responce, err := svc.FilterLogEvents(filterLogEventsInput) if err != nil { log.Fatalf("EROOR: %s", err) } if len(responce.Events) == 0 { log.Print("ログデータなし") } else { api := slack.New(os.Getenv("SLACK_ACCESS_TOKEN")) for _, event := range responce.Events { log.Printf("Event: %s", event) message := fmt.Sprintf(` AlarmName: %s, LogStreamName: %s, Message: %s `, cloudwatchAlertMessage.AlarmName, *event.LogStreamName, *event.Message) api.PostMessage(os.Getenv("SLACK_CHANNEL"), slack.MsgOptionText(message, false)) } } } func main() { lambda.Start(Handler) }
5. 動作確認
- 実際にデプロイして動作を確認します。
- 監視対象のアプリケーションを動かし、以下のように Slack にメッセージが投稿されれば OK です。
- メッセージの整形はもうちょっとこだわりたいですね。
まとめ
- ClouwWatch でログ監視をし、特定文字列が含まれる場合にアラームを上げる設定ができました。
- アラームが上がった際に SNS のトピックに送ることで、Lambda と連携できます。
- SNS のトピックから CloudWatch のログをたどることもできました。
- 一部、AWS SDK for Go のマニュアルを読み込まなくてはなりませんでしたが、以下の実装例が非常に役立ちました。
参考資料
- 【AWS】CloudWatch - Qiita
- AWS初心者入門 第6回 AWSの監視ツール「CloudWatch」で何ができるの? | 連載コラム 元SEママの情シスなりきりAWS奮闘記 | マネージドクラウド with AWS
- 上記 2 つは、CloudWatch の概要説明について、参考にしました。
- Amazon Simple Notification Service(SNS)の基本的な仕組み – ゴミ箱
- SNS の概要説明について、参考にしました。
- CloudWatchでエラーログの内容を通知させたい - Qiita
- CloudWatch上のエラーログをslackに通知する - Qiita
- 上記 2 つは、 Subscriber の実装の際に参考にしました。