AWS LambdaのKotlinからログをJSONでいい感じに出す

2024.10.15
AWS LambdaのKotlinからログをJSONでいい感じに出す
この記事をシェアする

AWS Lambdaを使用してサーバーレスなアプリケーションを構築する際、適切なログ出力はデバッグやモニタリングにおいて非常に重要な役割を果たします。特にJSON形式でログを出力することは、構造化データとして扱いやすく、CloudWatch Logsや他のログ解析ツールでの利用が容易になります。

この記事では、Kotlinを使ってSpring Cloud FunctionベースのLambda環境で、SLF4Jを介したLog4j2によるJSON形式のログ出力方法を紹介します。また、KotlinLoggingを使って簡潔に記述できる方法をサンプルコードと共に解説します。

AWS Lambdaにおいて、JSONでログを出すための基本方針

AWSのベストプラクティスに従いましょう。

かなりざっくり要約すると、下記のようになります。

  1. Lambdaの設定画面から、ログフォーマットを「JSON」に変更
  2. AWSの公式ドキュメントで推奨されるロガーを使う

これだけで下記のようなJSON形式のログが出力されます。自動で、timestamp、level、AWSRequestIdが出力されるので、デバッグ時に便利です。

{
    "timestamp": "2024-10-13T12:05:42.394Z",
    "level": "INFO",
    "message": "Hello, World!",
    "logger": "com.example.HelloService",
    "AWSRequestId": "xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx"
}

AWSの公式ドキュメントで推奨されるロガー

AWS Lambdaでログを出力する際、AWSの公式ドキュメントでは以下の3つの方法を推奨しています。

この記事では「方法2 : SLF4JのAPIを介して、Log4j2を使用する」を採用します。

方法1 : Lambda Powertoolsを使用する

Lambda Powertoolsは、ログ出力、トレーシング、メトリクス収集などの機能を提供するライブラリで、特にサーバーレス環境での観測可能性を強化するのに役立ちます。

方法2 : SLF4JのAPIを介して、Log4j2を使用する

SLF4Jは、一般的なログAPIであり、Log4j2などのログ実装と組み合わせて利用します。この方法は、JavaやKotlinプロジェクトでよく使われる標準的な手法です。

方法3 : LambdaのContextオブジェクトから取得できるLambdaLoggerを使用する

LambdaのContextオブジェクトから取得できるLambdaLoggerを使用してログを出力する方法です。軽量な方法ですが、より柔軟性に欠ける場合があります。

なぜSLF4J + Log4j2を選択するのか?

今回のブログでは、「2. SLF4JのAPIを介して、Log4j2を使用する方法」を採用します。その理由は、Spring Cloud FunctionがSLF4JとLog4j2をサポートしているためです。現時点で、Spring Cloud FunctionはLambda Powertoolsには対応していません(詳細はこちらのGitHub Issueを参照してください)。

KotlinLoggingを使ったシンプルで効果的なログ出力

KotlinでSLF4Jを使う際、KotlinLoggingを使用すると、非常に簡潔で見やすいコードが書けます。KotlinLoggingはSLF4Jのラッパーライブラリで、シンプルな記述と強力なログ機能を提供します。以下のようなコード例で、ログを簡潔に出力できます。

private val logger = KotlinLogging.logger { }
logger.info { "Hello, World!" }

このように、KotlinLoggingを使うことで、infoやdebugといったログレベルを指定しつつ、ラムダ式を使ってシンプルなログ出力が可能になります。

設定方法

SLF4J + Log4j2を使用する場合のLog4j2の設定ファイル

SLF4J + Log4j2を使用する場合、設定ファイルの配置が必要です。(AWSドキュメントの記載から引用

<Configuration>
    <Appenders>
        <Lambda name="Lambda" format="${env:AWS_LAMBDA_LOG_FORMAT:-TEXT}">
            <LambdaTextFormat>
                <PatternLayout>
                    <pattern>%d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1} - %m%n</pattern>
                </PatternLayout>
            </LambdaTextFormat>
            <LambdaJSONFormat>
                <JsonTemplateLayout eventTemplateUri="classpath:LambdaLayout.json"/>
            </LambdaJSONFormat>
        </Lambda>
    </Appenders>
    <Loggers>
        <Root level="${env:AWS_LAMBDA_LOG_LEVEL:-INFO}">
            <AppenderRef ref="Lambda"/>
        </Root>
        <Logger name="software.amazon.awssdk" level="WARN"/>
        <Logger name="software.amazon.awssdk.request" level="DEBUG"/>
    </Loggers>
</Configuration>

LambdaJSONFormatの部分がポイントのようで、org.apache.logging.log4j:log4j-layout-template-jsonから取得したフォーマットファイルを指定しているようです。

Kotlinでのプロジェクト設定(Gradle編)

次に、今回のサンプルコードを動かすためのGradle設定について解説します。特に、SLF4JとLog4j2、KotlinLoggingを用いてJSON出力を行うための依存関係の設定が重要です。

下記がbuild.gradle.ktsの記述のうち、ログ出力に関係のある箇所になります。

import com.github.jengelman.gradle.plugins.shadow.transformers.PropertiesFileTransformer

val awsLambdaJavaLog4J2Version = "1.6.0"
val log4JLayoutTemplateJsonVersion = "2.17.1"
val log4JSlf4J2ImplVersion = "2.19.0"
val kotlinLoggingJvmVersion = "7.0.0"

dependencies {
    // Springを使う場合、SpringのロガーをLog4j2に置き換える必要あり
    implementation("org.springframework.boot:spring-boot-starter-log4j2")
    modules {
        module("org.springframework.boot:spring-boot-starter-logging") {
            replacedBy("org.springframework.boot:spring-boot-starter-log4j2", "Use Log4j2 instead of Logback")
        }
    }

    // LambdaとLog4j2の統合に必要
    implementation("com.amazonaws:aws-lambda-java-log4j2:$awsLambdaJavaLog4J2Version")
    implementation("org.apache.logging.log4j:log4j-layout-template-json:$log4JLayoutTemplateJsonVersion")
    implementation("org.apache.logging.log4j:log4j-slf4j2-impl:$log4JSlf4J2ImplVersion")

    // Kotlin Logging
    implementation("io.github.oshai:kotlin-logging-jvm:$kotlinLoggingJvmVersion")
}

// LambdaにJarをアップするためにshadowを使っている場合、必須。
tasks.shadowJar {
    // log4j2対応
    transform(com.github.jengelman.gradle.plugins.shadow.transformers.Log4j2PluginsCacheFileTransformer::class.java)
}

コードの全量はこちら。

サンプルコードでの実装例

以下は、AWS LambdaでAPIGatewayリクエストを処理する関数の例です。この関数では、リクエストのパスやHTTPメソッドなどの情報をJSON形式でログに記録しています。

package com.example

import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent
import com.example.models.HelloResponse
import io.github.oshai.kotlinlogging.KotlinLogging
import io.github.oshai.kotlinlogging.withLoggingContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class HelloConfig {

    private val logger = KotlinLogging.logger { } // ロガーを取得

    @Bean
    fun helloFunction(
        helloService: HelloService
    ): (APIGatewayProxyRequestEvent) -> APIGatewayProxyResponseEvent = { requestEvent ->
        withLoggingContext( // KotlinLoggingの機能で、Mapped Diagnostic Context (MDC)が可能
            "resource" to requestEvent.resource,
            "path" to requestEvent.path,
            "httpMethod" to requestEvent.httpMethod,
        ) {
            try {
                logger.debug { "start helloFunction" } // Debugレベルでログ出力
                val helloMessage = helloService.getHelloMessage()

                val response = HelloResponse(value = helloMessage)

                APIGatewayProxyResponseEvent().withStatusCode(200)
                    .withBody(Json.encodeToString(response))
            } catch (e: Exception) {
                logger.catching(e) // キャッチした例外を、スタックトレースとともにログ出力して握りつぶす
                APIGatewayProxyResponseEvent().withStatusCode(500)
            }
        }
    }
}

Spring Cloud Functionのボイラープレートが多くて、恐縮ですが、コメントが入っている部分がポイントになります。

Kotlin Loggingでは、Mapped Diagnostic Context (MDC)というログに付加情報を付けるSLF4JのAPIに対応していて、これが便利です。withLoggingContextブロック内の処理では、指定した付加情報を常にログに付けることができます。上記の例だと、常に、APIリクエストのURLパス等をログに出すようにしています。

Kotlin Loggingの便利機能として、ロガーのcatchingメソッドがあります。これはアプリ全体をTryブロックで囲んで、このcatchingに例外を渡してあげると、Uncaughtな例外を全部キャッチして、いい感じのJSON形式で出力してくれます。

{
    "timestamp": "2024-10-13T12:05:42.395Z",
    "level": "ERROR",
    "message": "catching",
    "logger": "com.example.HelloConfig",
    "errorType": "java.lang.RuntimeException",
    "errorMessage": "testException",
    "stackTrace": [
        {
            "class": "com.example.HelloService",
            "method": "getHelloMessage",
            "file": "HelloService.kt",
            "line": 13
        },
        {
            "class": "com.example.HelloConfig",
            "method": "helloFunction$lambda$3",
            "file": "HelloConfig.kt",
            "line": 29
        },
        {
            "class": "jdk.internal.reflect.DirectMethodHandleAccessor",
            "method": "invoke",
            "file": null,
            "line": -1
        },
        ...
    ],
    "AWSRequestId": "xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
    "httpMethod": "GET",
    "path": "/hello",
    "resource": "/hello"
}

catchingを使わずに、そのまま例外を捕まえないと次のように読みにくいログが出てしまいます。コールスタックがmessageの中に折りたたまれてしまっていますね。

{
    "timestamp": "2024-10-15T06:53:00.413Z",
    "message": "testException: java.lang.RuntimeException\njava.lang.RuntimeException: testException\n\tat com.example.HelloService.getHelloMessage(HelloService.kt:13)\n\tat com.example.HelloConfig.helloFunction$lambda$3(HelloConfig.kt:29)\n\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)\n\tat java.base/java.lang.reflect.Method.invoke(Unknown Source)\n\tat org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:355)\n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)\n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)\n\tat org.springframework.aop.framework.adapter.AfterReturningAdviceInterceptor.invoke(AfterReturningAdviceInterceptor.java:57)\n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)\n\tat org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)\n\tat org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)\n\tat org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:223)\n\tat jdk.proxy2/jdk.proxy2.$Proxy59.invoke(Unknown Source)\n\tat org.springframework.cloud.function.context.config.KotlinLambdaToFunctionAutoConfiguration$KotlinFunctionWrapper.invoke(KotlinLambdaToFunctionAutoConfiguration.java:124)\n\tat org.springframework.cloud.function.context.config.KotlinLambdaToFunctionAutoConfiguration$KotlinFunctionWrapper.apply(KotlinLambdaToFunctionAutoConfiguration.java:99)\n\tat org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry$FunctionInvocationWrapper.invokeFunctionAndEnrichResultIfNecessary(SimpleFunctionRegistry.java:982)\n\tat org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry$FunctionInvocationWrapper.invokeFunction(SimpleFunctionRegistry.java:928)\n\tat org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry$FunctionInvocationWrapper.doApply(SimpleFunctionRegistry.java:764)\n\tat org.springframework.cloud.function.context.catalog.SimpleFunctionRegistry$FunctionInvocationWrapper.apply(SimpleFunctionRegistry.java:592)\n\tat org.springframework.cloud.function.adapter.aws.FunctionInvoker.handleRequest(FunctionInvoker.java:91)\n\tat java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)\n\tat java.base/java.lang.reflect.Method.invoke(Unknown Source)\n\n",
    "level": "ERROR",
    "AWSRequestId": "1802fd38-c3cf-4fcf-b1a2-1f45eb03ad0e"
}

終わりに

今回は、AWS Lambda上でKotlinを使ってJSON形式でログを出力する方法について解説しました。SLF4JとLog4j2を組み合わせ、KotlinLoggingを使用することで、シンプルで効果的なログ記述が可能となります。

Spring Cloud Functionを使用する場合は、現時点ではLambda Powertoolsに対応していないため、今回のようなSLF4J + Log4j2によるアプローチが有効です。今後、Lambda Powertoolsが対応する可能性もありますので、その際はそちらも検討してみると良いでしょう。

この記事をシェアする
著者:酒井亮太郎
シナモロール