🐥

【AWS】CloudWatchLogs の Lmabda サブスクリプションフィルターからエラーの直前のログを含めてアラームをあげる

2024/08/18に公開

背景

CloudWatch を使用してログ監視を行う場合、マネージドな機能がないので、Lambda サブスクリプションフィルターを使用して特定の文字列が出力された場合に SNS トピックをトリガーする作りこみをしている方は多いと思います。
ただ、普通にやるとエラー内容しかメールに添付できません。
エラーの直前のログはマネコンにログインして、ロググループ>ログストリームを確認する必要があります。
メールにタイムスタンプやログストリーム名を記載してても毎回やるのは結構めんどくさいです。
メールにエラー直前のログまで添付すれば解析の時間を短縮できるため、今回はそれを実現していきます。

構成

使用するサービス・機能は以下の通り

  • CloudWatch Logs
  • Lambda サブスクリプションフィルター
  • Lambda
  • SNS

Lambda のコード

python を採用しました。

import base64
import logging
import json
import zlib
import datetime
import os
from datetime import datetime, timezone, timedelta
import boto3
from botocore.exceptions import ClientError


# logger初期化
logger = logging.getLogger()
logger.setLevel(logging.INFO)


#ハンドラー
def lambda_handler(event, context):
    logger.info("LOAD Function: " + context.function_name)

    #CloudWatchLogsから渡される値は圧縮エンコードされているためデコードして解凍
    cwlogs_raw_data = zlib.decompress(base64.b64decode(event['awslogs']['data']), 16+zlib.MAX_WBITS)
        
    #JSON読み込み
    cwlogs_raw_data_json = json.loads(cwlogs_raw_data)

    #Lambdaサブスクリプションフィルターから連携された情報からデータ取得
    account_id = cwlogs_raw_data_json['owner']
    log_group_name = cwlogs_raw_data_json['logGroup']
    log_stream_name = cwlogs_raw_data_json['logStream']
    log_timestamp = convert_time(int(cwlogs_raw_data_json['logEvents'][0]['timestamp']))
    log_message = cwlogs_raw_data_json['logEvents'][0]['message']
    target_timestamp = cwlogs_raw_data_json['logEvents'][0]['timestamp']

    #ログストリーム内のログデータを取得する
    logs_with_timestamp = get_logs_around_with_ids(log_group_name, log_stream_name)

    #timestampを基に前後のログを取得する
    before = os.environ['GET_LOG_BEFORE']
    context_logs = get_logs_by_timestamp(logs_with_timestamp, target_timestamp, before)

    #SNS連携&メール送信
    send_mail(account_id, log_group_name, log_stream_name, log_timestamp, log_message, context_logs)

    logger.info("END Function: " + context.function_name)


# CloudWatchLogs LogsStream内の全てのtimestampとmessageのペアを返す関数
def get_logs_around_with_ids(log_group_name, log_stream_name):
    logs_client = boto3.client('logs')
    response = logs_client.get_log_events(
        logGroupName=log_group_name,
        logStreamName=log_stream_name,
        limit=1000
    )
    
    logs_with_timestamp = [{'message': event['message'], 'timestamp': event['timestamp']} for event in response['events']]
    return logs_with_timestamp


# 取得したログのリストから指定したtimestampを持つログを探して、その前後のログを抽出してリストに格納する関数
def get_logs_by_timestamp(logs_with_timestamp, target_timestamp, before):
    for i, log in enumerate(logs_with_timestamp):
        if log['timestamp'] == target_timestamp:
            # 負の値は考えられないためmaxで抑制する
            start_index = max(0, i - int(before))
            end_index = i + 1
            return [log['message'] for log in logs_with_timestamp[start_index:end_index]]
    return []


# タイムスタンプ時刻変換用関数
def convert_time(raw_timestamp):
    # UNIXミリ秒から秒への変換
    timestamp_sec = raw_timestamp / 1000
    dt = datetime.fromtimestamp(timestamp_sec, timezone(timedelta(hours=9)))
    # ISO:8601形式に変換
    timestamp_jst = dt.isoformat()

    return timestamp_jst


# SNS連携用関数
def send_mail(owner, loggroup, logstream, logtimestamp, message, expand_mesages):
    sns_client = boto3.client('sns')
    sns_topic_arn = os.environ['SNS_TOPIC_ARN']
    messages = expand_mesages

    sns_client.publish (
        TopicArn = sns_topic_arn,
        Subject = "ログ監視アラーム",
        Message =(
            "ログ グループ名: " + loggroup + "\n" +
            "ログ ストリーム名: " + logstream + "\n" +
            "ログ 検知日時(JST): " + logtimestamp + "\n" +
            "ログ 内容: " + message + "\n" +
            "エラーログの直前のログ:\n" + 
            "\n".join(messages) + "\n"
        )
    )

工夫したところ

ログストリームからエラーログの直前のログを取得するところ。
get_log_eventsメソッドでとってきていますが、response 内に eventId とかがなかったので、しょうがなく timestamp でエラーログを特定して、その直前〇個のログを取得して、配列に入れるようにしました。
本当は eventId みたいな値でとらないと重複の可能性がありますが、CloudWatchLogsに関しては、ミリ秒単位のUNIX時間で出るので、そこまで重複は気にしていません・・・('ω')
調べたところ、boto3 の当該メソッドでは、ログをクエリするのにeventIdとかは使えないようです。
また、Reponse Syntax には以下の情報が出力されると記載がありました。

  • timestamp
  • message
  • ingestionTime
  • nextForwardToken
  • nextBackwardToken

参考
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/logs/client/get_log_events.html

生データ

cwlogs_raw_data:b'
{
    "messageType": "DATA_MESSAGE",
    "owner": "XXXXXXXX",
    "logGroup": "failure-notification-logs",
    "logStream": "test",
    "subscriptionFilters": [
        "failure-notification"
    ],
    "logEvents": [
        {
            "id": "38445998629021071516356264961554827036108240146371706880",
            "timestamp": 1723978202825,
            "message": "error"
        }
    ]
}

get_event_logメソッドで取得した場合の生データ

events: [
{
timestamp: 1723970143845,
message: test,
ingestionTime: 1723970144794
},
{
timestamp: 1723970166214,
message: [ERROR]This is a test!!!,
ingestionTime: 1723970167276
},
{
timestamp: 1723973829766,
message: hogehoge,
ingestionTime: 1723973830883
},
{
timestamp: 1723973841634,
message: [ERROR]This is a test!!!,
ingestionTime: 1723973842752
},
{
timestamp: 1723973852027,
message: fugafuga,
ingestionTime: 1723973853137
},
{
timestamp: 1723975568373,
message: [ERROR]This is a test!!!,
ingestionTime: 1723975569421
},
{
timestamp: 1723975645329,
message: chokuchokuchokuchoku,
ingestionTime: 1723975646547
},
{
timestamp: 1723975650272,
message: [ERROR]This is a test!!!,
ingestionTime: 1723975651316
}
]

Discussion