【CDK】Lambda Python で構造化された JSON ログをカスタムフォーマッターにしてみる

【CDK】Lambda Python で構造化された JSON ログをカスタムフォーマッターにしてみる

Clock Icon2024.08.21

はじめに

データ事業本部ビッグデータチームのyosh-kです。
今回はLambda PythonでJson logを使用時にカスタムフォーマッターを実装してみたいと思います。

前提

実装コード

今回の実装コードについては、Github上に格納してあるのでご確認いただければと思います。

https://github.com/cm-yoshikikasama/blog_code/tree/main/41_lambda_logging_json_with_cdk

@41_lambda_logging_json_with_cdk % tree
.
├── README.md
├── cdk
│   ├── bin
│   │   └── app.ts
│   ├── cdk.json
│   ├── jest.config.js
│   ├── lib
│   │   └── lambda-logging-json-stack.ts
│   ├── package-lock.json
│   ├── package.json
│   ├── parameter.ts
│   ├── test
│   │   └── app.test.ts
│   └── tsconfig.json
└── resources
    └── lambda
        ├── lambda_handler_custom.py
        ├── lambda_handler_default.py
        └── lib
            ├── check_log_custom.py
            ├── check_log_default.py
            └── get_logger.py

8 directories, 13 files

なぜログ形式としてJSONを選択するのか

デフォルトでは、Lambda関数はプレーンテキスト形式、つまり非構造化ログ形式でログを出力していました。これにより、ログのクエリやフィルタリングが難しくなってしまう場合もありました。ログ出力を JSON キー値のペアとしてキャプチャすると、関数のデバッグ時に検索やフィルタリングが簡単になります。JSON 形式のログでは、ログにタグやコンテキスト情報を追加することもできます。これにより、大量のログデータの自動分析が可能になります。開発ワークフローがプレーンテキストで Lambda ログを使用する既存のツールに依存していない限り、ログ形式として JSON を選択することをお勧めします。その中で今回カスタムフォーマッターを作成する意図としては、linenoやfuncNameといった情報がデフォルトで出力されないので追加したいと感じたためです。以下は参考記事になります。

https://aws.amazon.com/jp/blogs/news/introducing-advanced-logging-controls-for-aws-lambda-functions/
https://docs.aws.amazon.com/lambda/latest/dg/python-logging.html
https://docs.aws.amazon.com/lambda/latest/dg/monitoring-cloudwatchlogs-advanced.html
https://dev.classmethod.jp/articles/lambda-logging-update/

https://newrelic.com/jp/blog/how-to-relic/structured-logging

https://qiita.com/tadashiro_ninomiya/items/19c774898c68add6185e#構造化ロギングを導入する

実装

bin/app.ts

bin/app.ts
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { LambdaStack } from "../lib/lambda-logging-json-stack";
import { devParameter, prodParameter } from "../parameter";

const app = new cdk.App();

const envKey = app.node.tryGetContext("environment") ?? "dev"; // default: dev

let parameter;

if (envKey === "dev") {
  parameter = devParameter;
} else {
  parameter = prodParameter;
}

new LambdaStack(app, `CMKasamaLambdaJson${envKey.toUpperCase()}`, {
  description: `${parameter.projectName}-${parameter.envName}-test-tag`,
  env: {
    account: parameter.env?.account || process.env.CDK_DEFAULT_ACCOUNT,
    region: parameter.env?.region || process.env.CDK_DEFAULT_REGION,
  },
  tags: {
    Repository: `${parameter.projectName}-${parameter.envName}-test-tag`,
    Environment: parameter.envName,
  },

  projectName: parameter.projectName,
  envName: parameter.envName,
  app_log_level: parameter.app_log_level,
});

app.tsではparatemer.tsで指定したパラメータをもとにスタックを定義しています。

lib/lambda-logging-json-stack.ts

lib/lambda-logging-json-stack.ts
import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";

export interface LambdaStackProps extends cdk.StackProps {
  envName: string;
  projectName: string;
  app_log_level: string;
}

export class LambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: LambdaStackProps) {
    super(scope, id, props);

    const lambdaRole = new iam.Role(this, "LambdaExecutionRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AWSLambdaBasicExecutionRole"
        ),
      ],
    });

    const customLambdaName = `${props.projectName}-${props.envName}-json-custom-log-test-handler`;
    const defaultLambdaName = `${props.projectName}-${props.envName}-json-default-log-test-handler`;

    new lambda.Function(this, "JsonCustomLogTestHandler", {
      functionName: customLambdaName,
      runtime: lambda.Runtime.PYTHON_3_12,
      code: lambda.Code.fromAsset("../resources/lambda"),
      handler: "lambda_handler_custom.lambda_handler", // ハンドラー名を修正
      memorySize: 512,
      timeout: cdk.Duration.seconds(900),
      role: lambdaRole,
      environment: {},
      architecture: lambda.Architecture.ARM_64,
      loggingFormat: lambda.LoggingFormat.JSON,
      applicationLogLevelV2:
        lambda.ApplicationLogLevel[
          props.app_log_level as keyof typeof lambda.ApplicationLogLevel
        ],
    });
    new lambda.Function(this, "JsonDefaultLogTestHandler", {
      functionName: defaultLambdaName,
      runtime: lambda.Runtime.PYTHON_3_12,
      code: lambda.Code.fromAsset("../resources/lambda"),
      handler: "lambda_handler_default.lambda_handler", // ハンドラー名を修正
      memorySize: 512,
      timeout: cdk.Duration.seconds(900),
      role: lambdaRole,
      environment: {},
      architecture: lambda.Architecture.ARM_64,
      loggingFormat: lambda.LoggingFormat.JSON,
      applicationLogLevelV2:
        lambda.ApplicationLogLevel[
          props.app_log_level as keyof typeof lambda.ApplicationLogLevel
        ],
    });
  }
}

lambdaの設定としてloggingFormatにJSONを指定し、applicationLogLevelV2にてログレベルを指定します。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda.LoggingFormat.html

lambda.ApplicationLogLevel は AWS CDK の Lambda モジュールで定義されている列挙型(enum)です。これは、ログレベルを指定するために使用されます。as keyof typeof lambda.ApplicationLogLevelは TypeScript の型アサーションです。これにより、props.app_log_levellambda.ApplicationLogLevelの有効なキー("INFO" or "WARN" or "ERROR" or "DEBUG" or "TRACE" or "FATAL")であることを保証しています。

lambda.ApplicationLogLevel[] は、文字列のログレベル(例: "INFO")を対応する lambda.ApplicationLogLevel のenum(例: lambda.ApplicationLogLevel.INFO)に変換しています。

https://zenn.dev/harryduck/articles/9d09b1c133f9cd

parameter.ts

parameter.ts
import { Environment } from "aws-cdk-lib";

// Parameters for Application
export interface AppParameter {
  env: Environment;
  envName: string;
  projectName: string;
  app_log_level: string;
}

// Example
export const devParameter: AppParameter = {
  envName: "dev",
  projectName: "cm-kasama",
  env: {},
  app_log_level: "DEBUG",
  // env: { account: "xxxxxx", region: "ap-northeast-1" },
};

export const prodParameter: AppParameter = {
  envName: "prod",
  projectName: "cm-kasama",
  env: {},
  app_log_level: "INFO",
  // env: { account: "xxxxxx", region: "ap-northeast-1" },
};

devとprodでlog levelを変更したい場合はこのファイルの設定を修正します。

resources/lambda/lambda_handler_custom.py

resources/lambda/lambda_handler_custom.py

import json
from lib.get_logger import setup_logger
from lib.check_log_custom import check_log_test

logger = setup_logger()

def lambda_handler(event, context):
    # 各ログレベルでメッセージを出力
    logger.debug("This is a debug message")
    logger.info("This is an info message")
    logger.warning("This is a warning message")
    logger.error("This is an error message")
    logger.critical("This is a critical message")

    # エラーをシミュレート
    try:
        1 / 0
    except ZeroDivisionError:
        logger.exception("An exception occurred")

    check_log_test()

    return {
        "statusCode": 200,
        "body": json.dumps("Logging configuration inspected and tested!"),
    }

get_loggerファイルでlogのカスタムフォーマッターを定義した関数を呼び出し、簡単にlog level毎の出力を確認する実装としています。

resources/lambda/lambda_handler_default.py

resources/lambda/lambda_handler_default.py

import logging
import json
from lib.check_log_default import check_log_test

logger = logging.getLogger()

def lambda_handler(event, context):
    # 各ログレベルでテストメッセージを出力
    logging.debug("This is a debug message")
    logging.info("This is an info message")
    logging.warning("This is a warning message")
    logging.error("This is an error message")
    logging.critical("This is a critical message")

    # エラーをシミュレートしてスタックトレースを表示
    try:
        1 / 0
    except ZeroDivisionError:
        logging.exception("An exception occurred")

    check_log_test()

    return {
        "statusCode": 200,
        "body": json.dumps("Logging configuration inspected and tested!"),
    }

こちらはデフォルトのフォーマッターを用いた実装としています。

resources/lambda/lib/check_log_custom.py

resources/lambda/lib/check_log_custom.py
from lib.get_logger import setup_logger

logger = setup_logger()

def check_log_test():
    logger.debug("debug")
    logger.info("info")
    logger.error("error")
    logger.critical("critical")

カスタムフォーマッターで使用する別ファイルから呼び出した際のlog出力を確認するための処理になります。

resources/lambda/lib/check_log_default.py

resources/lambda/lib/check_log_default.py
import logging

logger = logging.getLogger()

def check_log_test():
    logger.debug("debug")
    logger.info("info")
    logger.error("error")
    logger.critical("critical")

デフォルトフォーマッターで使用する別ファイルから呼び出した際のlog出力を確認するための処理になります。

resources/lambda/lib/get_logger.py

resources/lambda/lib/get_logger.py
import logging
import json
import traceback

class CustomJsonFormatter(logging.Formatter):
    def format(self, record):
        log_entry = {
            "timestamp": self.formatTime(record),
            "level": record.levelname,
            "message": record.getMessage(),
            "logger": record.name,
            "requestId": getattr(record, "aws_request_id", None),
            "funcName": record.funcName,
            "lineno": record.lineno,
        }
        if record.exc_info:
            log_entry.update(
                {
                    "stackTrace": traceback.format_exception(*record.exc_info),
                    "errorType": record.exc_info[0].__name__,
                    "errorMessage": str(record.exc_info[1]),
                    "location": f"{record.pathname}:{record.funcName}:{record.lineno}",
                }
            )
        return json.dumps(log_entry)

def setup_logger():
    logger = logging.getLogger()
    # Lambdaのデフォルトハンドラーを取得し、カスタムフォーマッターを適用
    handler = logger.handlers[0]
    handler.setFormatter(CustomJsonFormatter())
    return logger

このコードは、カスタムJsonフォーマッターを実装しています。CustomJsonFormatter クラスは、ログレコードを構造化された JSON 形式に変換し、timestamplevelmessageloggerrequestIdfuncNamelinenoなどの重要な情報を含めます。デフォルトのフォーマッター設定ではないfuncNamelinenoを追加しています。例外時は、record.exc_infoがTrueとなり、log情報を追加しています。get_logger() 関数は、このカスタムフォーマッターを使用して既存のハンドラーに適用しています。logging.LogRecordというPythonの組み込みクラスを直接修正することは、予期しない副作用を引き起こす可能性があるため、今回はしていません。他の方法があれば別途追記したいと思います。

デプロイ

package.jsonがあるディレクトリで依存関係をインストールします。

npm install

次にcdk.jsonがあるディレクトリで、CDKで定義されたリソースのコードをAWS CloudFormationテンプレートに合成(変換)するプロセスを実行します。

npx cdk synth --profile <YOUR_AWS_PROFILE>

同じくcdk.jsonがあるディレクトリでデプロイコマンドを実行します。--allはCDKアプリケーションに含まれる全てのスタックをデプロイするためのオプション、--require-approval neverはセキュリティ的に敏感な変更やIAMリソースの変更を含むデプロイメント時の承認を求めるダイアログ表示を完全にスキップします。neverは、どんな変更でも事前確認なしにデプロイすることを意味します。今回は検証用なので指定していますが、慎重にデプロイする場合は必要のないオプションになるかもしれません。-cでenvironmentを指定し、環境に合わせたデプロイを行います。

npx cdk deploy --all --require-approval never -c environment=dev --profile <YOUR_AWWS_PROFILE>

実行結果

lambda_handler_default.py

Lambdaをテスト実行した際のCloudWatch Logsになります。linenoやfuncNameがないので、どの箇所でエラーになったのかがぱっと見でわかりづらいことがあると感じます。

Screenshot 2024-08-21 at 10.16.33
Screenshot 2024-08-21 at 10.17.20

lambda_handler_custom.py

同じくLambdaをテスト実行した際のCloudWatch Logsになります。こちらはlinenoとfuncNameを追加したので、どの箇所でエラーになっているか分かり易くなっていると思います。

Screenshot 2024-08-21 at 10.16.06
Screenshot 2024-08-21 at 10.16.15

最後に

カスタムフォーマッターの場合は、デフォルトフォーマッターを上書きしてしまうので、デフォルトが変わった際の対応が必要となります。デフォルトフォーマッターでも詳細なデバッグ情報が含まれていないというデメリットもありますので、プロジェクトのニーズと運用効率のバランスを考慮して選択すれば良いと考えています。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.