🤖

CDKv2を使ってLambdaのログ内容からエラー通知をする構成を作る

2022/02/09に公開

はじめに

AWS Lambdaを利用を利用しているとログ出力はAmazon CloudWatch logsにしていることが多いかと思います。
ログ出力からアラームをあげるような設定にはCloudwatchのサブスクリプションフィルタを使って設定する方法が知られています。
こちら、CDKにて丸ごと設定を行う書き方が意外とでてこなかったのでまとめてみました。

ログに応じてLambdaが起動することになりますので、試してみる際には費用やクオータには注意してください。

全体構成(準備等)

cdk関連

使用したバージョンは

  • aws-cdk@2.10.0

で、typescriptを使っています。最近バージョン2が正式版となり、v1のサポート期限もアナウンスされているのでバージョン2を利用してみました。
https://aws.amazon.com/jp/about-aws/whats-new/2021/12/aws-cloud-development-kit-cdk-generally-available/
こちらからマイグレーションの案内等確認できます。

後述のディレクトリ構成でも触れますが、npx cdk init app --language typescriptをして、トップディレクトリにfunctionsを追加してそちらにlambdaのコードを置く形にしています。
lambdaはCDKからaws-cdk-lib/aws-lambda-nodejsを使って読み込む形にしています。
こちらのaws-lambda-nodejsを使うとesbuildでビルドしてdeployできるので、コードをまとめることができて便利です。(node_modulesが含まれたりもしないし、lambdaごとにnpmパッケージを作らなくてよい)
esbuild、インストールしていない場合にはビルド用のdocker仮想環境が動いてくれますが、localビルドしたほうが高速そうだったので、今回は以下の公式ドキュメントに従ってインストールしました。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs-readme.html#local-bundling

Lambda関連

2つのLambdaを用意しました。

  • logTest.ts
  • filterEventReceiver.ts

logTestは今回のテストのためにエラーを出すお試し関数、filterReceiverがサブスクリプションフィルタから呼び出される関数です。
内部で利用するpackageのインストール等については省略します。
今回はloggerを使ってみるかということで、log4js@6.4.1を入れています。

ディレクトリの構成

cdk initした以外にはfunctions以下に必要なパッケージをインストールしています。
細かい設定ファイルを除くとこのような構成です。
binの下のコードは今回は全く触っていません。

-bin
  |-{appName}.ts
-functions
  |-node_modules
  |-filterEventReceiver.ts
  |-logTest.ts
  |-package.json
  |-tsconfig.json
-lib
  |-{appName}-stack.ts

lib下のstackのコード

全体像はこんな感じです。
冒頭がLambdaの定義、後半でサブスクリプションフィルタの設定をしています。

lib/{appName}-stack.ts
import { Duration, Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'
import * as destinations from 'aws-cdk-lib/aws-logs-destinations'
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';

export class {スタック名} extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const logTest = new lambda.NodejsFunction(this, 'testLambda', {
      functionName: "testLambdaName",
      runtime: Runtime.NODEJS_14_X,
      entry: 'functions/logTest.ts',
      timeout: Duration.seconds(10),
      description: "test log lambda",
    })

    const filterReceiver = new lambda.NodejsFunction(this, 'filterEventReceiver', {
      functionName: "filterEventReceiverName",
      runtime: Runtime.NODEJS_14_X,
      entry: 'functions/filterEventReceiver.ts',
      timeout: Duration.seconds(10),
      retryAttempts: 0,
      description: "filter receiver",
    })

    const logGroup = logs.LogGroup.fromLogGroupName(this, "testLambdaLogGroup", logTest.logGroup.logGroupName)
    logGroup.addSubscriptionFilter('Subscription', {
      destination: new destinations.LambdaDestination(filterReceiver),
      filterPattern: logs.FilterPattern.anyTerm("[ERROR] default", "[FATAL] default", "ERROR\tInvoke Error")
    })
  }
}

サブスクリプションフィルタの設定について

サブスクリプションフィルタを設定しているのは

    const logGroup = logs.LogGroup.fromLogGroupName(this, "testLambdaLogGroup", logTest.logGroup.logGroupName)
    logGroup.addSubscriptionFilter('Subscription', {
      destination: new destinations.LambdaDestination(filterReceiver),
      filterPattern: logs.FilterPattern.anyTerm("[ERROR] default", "[FATAL] default", "ERROR\tInvoke Error")
    })

この箇所になります。
fromLogGroupNameでロググループ名からロググループを持ってこれるので、logTestのLambdaのロググループ名から引っ張ってきています。
ここはCDKならではの便利な箇所かなと思いました。

また、filterPatternの記述方法はちょっと癖がありまして
https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-logs.FilterPattern.html
目的に応じてMethodを呼び分ける必要があります。
以下はサブスクリプションフィルタを直接書く場合の構文ですが、こちらを意識するとよいかと思います。
https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html
今回書いたfilterPattern: logs.FilterPattern.anyTerm("[ERROR] default", "[FATAL] default", "ERROR\tInvoke Error")
?"[ERROR] default" ?"[FATAL] default" ?"ERROR Invoke Error"
と同義で、ORパターンのマッチングになります。
冒頭2つはloggerに合わせたパターン、最後の一つはLambdaがエラー終了したときにCloudwatchに出てくるエラーメッセージにしてみました。
後述するLambdaのコードで利用している、log4jsではデフォルトのloggerにてlogger.{ログレベル}({メッセージ})として出力すると、各ログレベルに合わせたカラー設定と一緒に[{ログレベル}] default - {メッセージ}という形で出力されるので、この[{ログレベル}] defaultの部分をひっかける形です。

anyTermの部分をallEventsにするとすべてのイベントがマッチするフィルタ、上記CDKのドキュメントのExampleに出てくるanyTermGroupではANDとORを組み合わせたフィルタが組めたりするようです。
用途に合わせてメソッドを選ぶ必要があります。

Lambdaのコード

こちらはテストのためのコードなのでさらっと
ログを吐くLambda、logTest.tsがこちら

functions/logTest.ts
import log4js from 'log4js'
const logger = log4js.getLogger()
logger.level = 'debug'

export const handler = () => {
  logger.debug("aiueo")
  logger.error("error")
  logger.fatal("fatal")
  throw new Error("bad situation ")

  return
}

サブスクリプションフィルタから起動するLambda、filterEventReceiver.tsがこちら

functions/filterEventReceiver.ts
import { Callback, CloudWatchLogsEvent, CloudWatchLogsDecodedData, Context } from 'aws-lambda';
import zlib from 'zlib'
import log4js from 'log4js'
const logger = log4js.getLogger()
logger.level = 'debug'

export const handler = async (input: CloudWatchLogsEvent, context: Context, callback: Callback) => {
  const payload = Buffer.from(input.awslogs.data, "base64")
  try {
    const result = await new Promise<Buffer>((resolve, reject) => {
      zlib.gunzip(payload, (err, result)=>{
        return (err ? reject(err): resolve(result))
    })})
    const parsedResult = JSON.parse(result.toString("ascii")) as CloudWatchLogsDecodedData
    logger.info(parsedResult.owner)
    logger.info(parsedResult.logGroup)
    logger.info(parsedResult.logStream)
    logger.info(parsedResult.subscriptionFilters)
    logger.info(parsedResult.messageType)
    logger.info(parsedResult.logEvents)
    callback(null, "")
  } catch (err) {
    logger.error(err)
    callback("failed to gunzip", null)
  }
}

こちらでインポートしているaws-lambdaというのはnpm i -D @types/aws-lambdaでインストールしている、lambda関連の型定義のライブラリです。
以前教えていただいて利用しています。
今回の場合だと、zipを解凍した後のデータの型CloudWatchLogsDecodedDataが定義されていたり、なかなか便利だなと思いました。

こちらのinputからの解凍部分は下記の公式ドキュメントを参考にしつつ、今後処理を追加するならasync/awaitで書きたいな、というのと、context.fail/context.succeedは古い書き方だったような・・・ということで書き換えてしまっています。
https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/SubscriptionFilters.html#LambdaFunctionExample

また、両者に共通するお話として、今回は省略した余談ですが、logger.level = process.evn.LOG_LEVEL ?? 'debug'としてやって、stack側で環境変数を渡してあげるようにすると一括管理ができるようになって便利で、私は愛用しています。

実行結果

logTestのほうのLambdaを実行してあげると無事filterEventReceiverも起動しており、
以下のようなログが出力されていました。(一部抜粋&整形してます)
無事すべての情報が出力されていそうです

log抜粋
{アカウントの番号}
/aws/lambda/testLambdaName
2022/02/08/[$LATEST]{ストリームを現す文字列}
[ '{スタック名}-testLambdaLogGroupSubscription{数字と文字列}' ]
DATA_MESSAGE
[
  ({
    id: {数字(イベントの識別子)},
    timestamp: 1644319800855,
    message:
      "\x1B[91m[2022-02-08T11:30:00.855] [ERROR] default - \x1B[39merror\n",
  },
  {
    id: {数字(イベントの識別子)},
    timestamp: 1644319800855,
    message:
      "\x1B[35m[2022-02-08T11:30:00.855] [FATAL] default - \x1B[39mfatal\n",
  },
  {
    id: {数字(イベントの識別子)},
    timestamp: 1644319800894,
    message:
      '2022-02-08T11:30:00.856Z\{ID}\tERROR\tInvoke Error \t{"errorType":"Error","errorMessage":"bad situation ","stack":["Error: bad situation "," at Runtime.handler (/var/task/index.js:5978:9)"," at Runtime.handleOnce (/var/runtime/Runtime.js:66:25)"]}\n',
  },
]

サブスクリプションフィルタはlogイベントにかかるフィルタなので、フィルタの構文にマッチしたイベントのみが送られている、OR条件で引っ掛けている場合はまとまって送られてくる、という結果になりました。
こちらはどうやら必ずまとめられるものでもないようなので、OR条件で引っ掛けてしまうと複数回lambdaが呼ばれるような形になりかねないようです。
今回はテストやログの整備が完ぺきではなく、抜け漏れ含めてカバーするような想定でORにしてみましたが、きっちり組むのであればもう少し検討の余地がありそうです。

おわりに

CDKでLambdaのログにサブスクリプションフィルタを追加するのは思った以上に簡単にかけました。
ロググループの名前さえあればよいので、アプリ内の複数のLamdaにアラートの設定を追加する場合なども手軽に管理ができそうです。
また、本題のCDKの話題ではなくなってしまいますが、サブスクリプションフィルタはあくまでCloud Watch Logsのイベントしか通知しないので、エラーログを丸ごとS3にコピーしたり、通知の中に前後の情報を含めるには追加で通知で渡されるlogGroupとlogStreamの情報を使ってlogを読み直す必要がありそうだな、と思いました。

気になること等あればコメントはお気軽にどうぞ!
記事を読んでいただきありがとうございました。

Discussion