AWS CloudWatch Logs で上がったアラームを検知して、Lambda 経由で Slack にエラーログを投稿する

CloudWatch Logs 収集対象のログ監視をしてアラームが上がってきた場合、SNS で通知することは可能ですが、エラーログの内容までは通知されません。そこで、SNS からの通知を基に、エラーログの内容を取得し、更に、その内容を Slack に投稿するようにしてみました。Lambda を使って Go で実装しました。

目次

前提

  • 以前の記事同様、Go の開発環境が整っている必要があります。
  • Go で実装した Lambda を使って Slack Bot を作る方法は下記の記事をご覧ください。

blog.linkode.co.jp

今回作成するものの構成

f:id:linkode-okazaki:20200206174922p:plain

  • 今回は、Lambda のエラーログ監視をし、エラーがあった場合に Slack に通知するものとします。
  • 処理の流れとしては上図のとおりです。
    1. 監視対象の Lambda で ClouwWatch にログを出力
    2. CloudWatch Logs で対象の Lambda のログを監視する。特定の文字列(今回は「ERROR」)があった場合にアラームを上げる。
    3. アラームから SNS のトピックにエラーが発生した旨の通知を送るよう依頼
    4. SNS のトピックから Lambda にアラームが発生した旨を通知
    5. Lambda の処理内で、通知されたアラームの内容から CloudWatch Logs の内容を取得
    6. 取得したエラーログの内容を Lambda から Slack に投稿する

サービスの概要

CloudWatch

f:id:linkode-okazaki:20200205192448p:plain

  • 公式サイト
  • CloudWatch は AWS リソースと、AWS で実行されているアプリをリアルタイムでモニタリングするマネージドサービスです。
  • 大きく分けて 3 つの機能があります。
    1. CloudWatch
      • 基本となる監視サービスです。
      • メトリクス という時系列のデータポイントのセットを基に監視したりグラフを作成したりできます。
        • CPU やメモリの使用量、利用料金など、標準で用意されているメトリクスがあります。
        • ユーザーでメトリクスを定義することもできます。
      • リソースの状況に応じて、メール送信や再起動、オートスケーリングをする設定ができます。
    2. CloudWatch Logs
      • AWS の各種サービスのログの監視、保管、アクセスができます。
      • モニタリングされているアプリやリソースのアクティビティは、ログイベントに記録されます。
      • メトリクスフィルター を定義することで、ログイベントから特定の文字列のフィルタリングが可能です。
    3. CloudWatch Events
      • AWS リソースの変更(イベント)を監視するために利用します。
      • インスタンスを削除した場合やコンソールに特定のユーザがログインした場合などを検知し、アラートを通知する、といったことが実現可能です。
  • 今回は、CloudWatch Logs でメトリクスフィルターを定義し、ログの監視を行います。

Simple Notification System(SNS

f:id:linkode-okazaki:20200205191748p:plain

  • 公式サイト
  • サーバーレスなアプリケーションで通知を実装できるサービスです。
  • 通知プロトコルとしては、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 のメトリクスフィルター定義

    1. で作成したアプリケーションのログの監視設定を行います。
  • AWS コンソールから CloudWatch を開き、左メニュー「ログ」→「ロググループ」から監視対象のアプリケーションを探します。 f:id:linkode-okazaki:20200205191712p:plain
  • メトリクスフィルターの一覧を開き、「メトリクスフィルターの作成」を選択 f:id:linkode-okazaki:20200205192828p:plain
  • フィルタリングする文字列を「フィルターパターン」に入力します。
    • 今回は「ERROR」とします。 f:id:linkode-okazaki:20200205191735p:plain
  • メトリクスフィルターのメトリックスの割当設定を行います。
    • 「メトリクス名前空間」は、ログの監視であることがわかるように名前をつけておきます。
    • 「メトリクス名」と「フィルター名」は同じで良いでしょう。
    • 「メトリクス値」は 1 とします。これにより、「ERROR」を含むログイベントのカウント増分が 1 となります。 f:id:linkode-okazaki:20200205191742p:plain
  • 設定を確認し、「メトリクスフィルターを作成」で作成を完了します。 f:id:linkode-okazaki:20200205191718p:plain

3. SNS のトピックの作成とアラームの定義

  • アラームが発生した場合の通知の送り先となる SNS トピックを作成します。AWS コンソールから「Simple Notification Service」を選択します。
  • 左メニュー「トピック」を開き、「トピックの作成」を選択します。 f:id:linkode-okazaki:20200205191810p:plain
  • トピック名と表示名を決め、「トピックの作成」をクリックします。
    • 作成後、トピックの ARN は控えておきます。 f:id:linkode-okazaki:20200205191739p:plain
  • アラームの設定を行います。当該のロググループのメトリクスフィルター一覧からメトリクスフィルターにチェックを入れた状態で「アラームを作成」を選択します。 f:id:linkode-okazaki:20200205191723p:plain
  • メトリクスの条件を指定します。
    • 今回は、「1 分間に『ERROR』を含むログ出力が 1 回でも発生する」という事象が発生した場合にアラームを通知するように設定します。 f:id:linkode-okazaki:20200205191752p:plain f:id:linkode-okazaki:20200205191731p:plain
  • 通知アクションの設定をします。「通知の送信先」として、先ほど設定したトピック名を入力します。 f:id:linkode-okazaki:20200205191757p:plain
  • 最後にアラーム名を入力してアラーム作成を完了します。
  • 実際にアラームが作動するかを確かめます。何もしていない状態では、「アラーム」は0件になっています。 f:id:linkode-okazaki:20200205191727p:plain
  • 当該アプリケーションを動作させ、エラーログを出力させると、アラームが発生します。 f:id:linkode-okazaki:20200205191806p:plain

4. エラーログ取得用の Lambda を実装

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
  }
}
  • この中の filterPatternlogGroupName により、取得したいログを抽出できます。

開始時刻と終了時刻の設定

  • ログデータの抽出のために、filterPatternlogGroupName の他に、対象となる期間を設定しておきます。
    • 指定しておかなければ、全期間のログが抽出対象となっていまいます。
  • 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 への投稿だけですので、「Bot User OAuth Access Token」を用います。
  • 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 に関して、以下の設定を記載しておきます:
    • 受け取る SNS の ARN
    • ログ取得に必要な IAM Role
    • SLACK の情報(環境変数
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 です。
    • メッセージの整形はもうちょっとこだわりたいですね。 f:id:linkode-okazaki:20200205192309p:plain

まとめ

  • ClouwWatch でログ監視をし、特定文字列が含まれる場合にアラームを上げる設定ができました。
  • アラームが上がった際に SNS のトピックに送ることで、Lambda と連携できます。
  • SNS のトピックから CloudWatch のログをたどることもできました。
  • 一部、AWS SDK for Go のマニュアルを読み込まなくてはなりませんでしたが、以下の実装例が非常に役立ちました。

参考資料