1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudWatch Logs Insightsを利用したログ監視

Last updated at Posted at 2024-12-06

ログ監視時にログの内容も通知したいけど、ロググループ単位でサブスクリプションフィルター設定したくないので、Logs Insightsを使ってみました。

AWS利用サービス

AWSで以下のサービスを利用して定期的にログ抽出します。

  • パラメータストア:ログ監視設定用
  • EventBridge:定期実行用
  • Lambda:抽出処理用
  • SNS:エラー通知用
  • CloudWatch Logs Insights:エラーログ抽出用

パラメータストア設定

通知に必要となる以下の情報はすべてパラメータストアで管理します。

設定項目

項目 概要
ロググループの一覧 監視対象のロググループを設定
抽出クエリ CloudWatch Logs Insights で利用するクエリ文字列

設定値の例

  • ロググループの一覧:1行1ロググループで設定
    /aws/lambda/hoge-func
    /aws/lambda/fuga-func
    
  • 抽出クエリ:エラーキーワード[ERROR]で抽出する場合
    fields @timestamp, @log, @message
    | filter @message like "[ERROR]"
    | parse @log "*:*" as account, logname
    | sort @timestamp asc
    | display (@timestamp + (9*60*60*1000)) as timestamp_jst, account, logname, @message
    

Lambda周り

Node20、AWS SDK v3で作成しています。
ちなみに、ロググループは最大50個まで対応しています。(Logs Insightsが1回で最大50件なので、件数チェックしてループすれば対応できるけど今回は不要なのでやってないです)

ソースコード

const { CloudWatchLogsClient, StartQueryCommand, GetQueryResultsCommand } = require('@aws-sdk/client-cloudwatch-logs')
const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns')
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm')

// CloudWatch Logsクライアントを作成
const cloudwatchlogsClient = new CloudWatchLogsClient({})

// SNSクライアントを作成
const snsClient = new SNSClient({})

// SSMクライアントを作成
const ssmClient = new SSMClient({})

// 起動間隔(分)を取得
const interval = parseInt(process.env.STARTUP_INTERVAL)

// Logインサイトでは直近数分のログが検索できないため
// 差し引く時間を設定
// ※設定は分になるため、秒に変換
const latency = parseInt(process.env.LOG_QUERY_LATENCY_TIME) * 60

// log insight のクエリで取得したフィールド値の取得
const getField = (result, fieldName) => {
  const fieldValue = result.find(field => field.field === fieldName)
  return fieldValue ? fieldValue.value : ''
}

// export const handler = async (event) => {
module.exports.handler = async (event) => {

  console.log(`event: ${JSON.stringify(event)}`)

  // Lambdaが起動した時間を取得し、Dateオブジェクトに変換
  let eventTime = new Date(event.time)

  // 秒とミリ秒を切り捨てて、分単位に丸める
  eventTime.setUTCSeconds(0, 0)

  // 分を取得
  let minute = eventTime.getUTCMinutes()

  // 現在の時間を5分単位に丸める
  let currentWindowEndMinute = Math.floor(minute / interval) * interval

  // ウィンドウの終了時間を設定
  let windowEndTime = new Date(eventTime)
  windowEndTime.setUTCMinutes(currentWindowEndMinute)

  // ウィンドウの開始時間を設定(終了時間からinterval分引く)
  let windowStartTime = new Date(windowEndTime)
  windowStartTime.setUTCMinutes(windowEndTime.getUTCMinutes() - interval)

  // 開始時間と終了時間をタイムスタンプ(ミリ秒)に変換
  let startTime = windowStartTime.getTime()
  let endTime = windowEndTime.getTime() - 1 // 終了時間ぴったりのログは含まれない

  // 開始時間と終了時間をタイムスタンプ(秒)に変換(CloudWatch Logs APIの仕様)
  let startTimeInSeconds = Math.floor(startTime / 1000) - latency
  let endTimeInSeconds = Math.floor(endTime / 1000) - latency

  try {
      // パラメータストアからロググループ名を取得
      const logGroupNamesParamName = process.env.LOG_GROUPS_PARAMETER_STORE
      const getLogGroupNamesCommand = new GetParameterCommand({
          Name: logGroupNamesParamName,
          WithDecryption: false, // 暗号化されていない場合
      })
      const logGroupNamesResponse = await ssmClient.send(getLogGroupNamesCommand)
      const logGroupNamesString = logGroupNamesResponse.Parameter.Value

      // 改行で分割して配列に変換
      const logGroupNames = logGroupNamesString.split('\n').map(name => name.trim()).filter(name => name !== '')

      // パラメータストアからクエリ文字列を取得
      const queryStringParamName = process.env.LOG_QUERY_PARAMETER_STORE
      const getQueryStringCommand = new GetParameterCommand({
          Name: queryStringParamName,
          WithDecryption: false, // 暗号化されていない場合
      })
      const queryStringResponse = await ssmClient.send(getQueryStringCommand)
      const queryString = queryStringResponse.Parameter.Value

      // クエリを開始するためのパラメータを設定
      const params = {
          logGroupNames: logGroupNames,
          startTime: startTimeInSeconds,
          endTime: endTimeInSeconds,
          queryString: queryString,
      }

      // クエリを開始
      const startQueryCommand = new StartQueryCommand(params)
      const startQueryResponse = await cloudwatchlogsClient.send(startQueryCommand)

      // クエリIDを取得
      const queryId = startQueryResponse.queryId

      // クエリの結果を取得するためのループ
      let queryStatus = 'Running'
      let queryResults = []
      while (queryStatus === 'Running' || queryStatus === 'Scheduled') {
          // 1秒待機してから再度チェック
          await new Promise(resolve => setTimeout(resolve, 1000))

          // クエリの結果を取得
          const getQueryResultsCommand = new GetQueryResultsCommand({ queryId })
          const getQueryResultsResponse = await cloudwatchlogsClient.send(getQueryResultsCommand)

          // クエリのステータスを更新
          queryStatus = getQueryResultsResponse.status

          // クエリが完了した場合、結果を取得
          if (queryStatus === 'Complete') {
              queryResults = getQueryResultsResponse.results
          }
      }

      // エラーログが存在するかチェック
      if (queryResults && queryResults.length > 0) {
          // エラーログが存在する場合、SNSでメール通知を送信

          // SNSトピックARNを環境変数から取得
          const topicArn = process.env.SNS_TOPIC_ARN

          // エラー発生のログ
          console.log(`${queryResults.length}件のエラーが検出されました`)

          for (const result of queryResults) {
              // 各結果からタイムスタンプとメッセージを抽出
              const timestampJst = getField(result, 'timestamp_jst')
              const logMessage = getField(result, '@message')
              const logname = getField(result, 'logname')
              
              // ★タイトルや通知メッセージは必要に応じて変更
              const message = `発生日時: ${timestampJst}\n対象ログ: ${logname}\n\nログメッセージ: \n${logMessage}`
              
              // SNSメッセージを送信
              const publishCommand = new PublishCommand({
                  TopicArn: topicArn,
                  Subject: `[エラーログ通知]${logname}`,
                  Message: message,
              })
              await snsClient.send(publishCommand)
          }

      } else {
          // エラーログが存在しない場合の処理(必要に応じて)
          console.log('エラーログは検出されませんでした')
      }

  } catch (error) {
      // エラーが発生した場合の処理
      console.error('エラーが発生しました:', error)
      throw error
  }
}

環境変数

ソースコード上の環境変数ごとに説明は以下の通り

環境変数 設定値説明
LOG_GROUPS_PARAMETER_STORE 対象ロググループ一覧のパラメータストア名
LOG_QUERY_PARAMETER_STORE 抽出クエリのパラメータストア名
STARTUP_INTERVAL EventBridgeでの呼び出し間隔時間(分)を設定
LOG_QUERY_LATENCY_TIME Logインサイトでは直近数分のログが検索できないため差し引く時間(分)を設定
SNS_TOPIC_ARN 通知先のSNSトピックARN
TZ Asia/Tokyo 固定

必要権限

Lambdaに必要なIAM設定は以下の通り

権限 必要な設定
ログ記録 logs:CreateLogGroup
logs:CreateLogStream
logs:PutLogEvents
ログ抽出 logs:StartQuery
logs:GetQueryResults
SNS通知 sns:Publish
パラメータストア取得 ssm:GetParameter

注意点

CloudWatch Logsにログが出力されてからインデックス化が行われて、CloudWatch Logs Insightsで抽出できるまでタイムラグが2分ぐらいあるので、タイムラグを注意して設定する必要があります。

例えば、環境変数に以下の値が設定されている場合、Lambda起動時にチェックするログは3分前~8分前が対象になります。

  • タイムラグ:3分 ※LOG_QUERY_LATENCY_TIMEのこと
  • ログチェック間隔:5分 ※STARTUP_INTERVALのこと

特定キーワードの除外設定をする場合

パラメータストアでクエリを管理しているため、対象キーワードで除外するfilternot likeで追加するのみで対応できます。
※正規表現で指定することもできるので結構柔軟にできるはずです。

fields @timestamp, @log, @message
| filter @message like "ERROR"
| filter @message not like "hogehoge"
| filter @message not like "fugafuga"
| sort @timestamp asc
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?