本番環境でテキストログを使っていると、CloudWatch LogsやElasticsearchで特定リクエストを追いかけたいのに正規表現でパースしないと検索できない、という場面に出くわしますよね。

JSON構造化ログに切り替えると、ログが構造化されたデータになるのでフィールド指定で検索・集計できます。リクエストIDやユーザーIDもフィールドとして持てるため、トレーサビリティが格段に上がります。

なぜJSON構造化ログが必要か

テキストログでは収集エージェントが正規表現でフィールドを切り出す必要があります。フォーマットが少し変わるとパース設定も壊れます。

JSONなら最初からフィールドが分かれているので、CloudWatch Logs Insightsで fields @timestamp, requestId | filter level = "ERROR" のように即座に検索できます。コンテナ環境ではstdoutへのJSON出力がログ収集エージェントとの相性も良いです。

logstash-logback-encoderの依存追加

Spring Bootが管理していないライブラリなので、バージョンを明示します。

Maven

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>8.0</version>
</dependency>

Gradle

implementation 'net.logstash.logback:logstash-logback-encoder:8.0'

Kotlin DSLの場合は implementation("net.logstash.logback:logstash-logback-encoder:8.0") と記述してください。最新バージョンは GitHub Releases で確認できます。

logback-spring.xmlでJSON出力を設定する

LogstashEncoder@version@timestamp を含むlogstash互換形式で出力します。一方 JsonEncoder はこれらを省いたシンプルなJSON出力です。ELKスタックを使う場合は LogstashEncoder、それ以外のシンプルな用途では JsonEncoder が軽量です。

src/main/resources/logback-spring.xml を作成します。

<configuration>
    <appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"my-service","env":"production"}</customFields>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE_JSON"/>
    </root>
</configuration>

customFields でアプリ名や環境名などの固定フィールドを追加できます。

MDCの仕組みを理解する

MDC(Mapped Diagnostic Context)はスレッドローカルなキーバリューストアです。MDC.put("requestId", "abc123") とセットしておくと、同じスレッドで出力されるすべてのログに自動的に requestId フィールドが付与されます。

LogstashEncoder はMDCの内容を自動でJSONフィールドとして出力してくれるので特別な設定は不要です。

OncePerRequestFilterでリクエストIDを自動付与する

@Component
public class MdcRequestFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {
        String raw = request.getHeader("X-Request-ID");
        // ログインジェクション対策: 長さ上限(128文字)と制御文字チェック
        String requestId = (raw != null && raw.length() <= 128
                && raw.chars().noneMatch(c -> c < 0x20))
                ? raw : UUID.randomUUID().toString();
        try {
            MDC.put("requestId", requestId);
            response.setHeader("X-Request-ID", requestId);
            filterChain.doFilter(request, response);
        } finally {
            MDC.clear();
        }
    }
}

finally ブロックでの MDC.clear() が重要です。スレッドプールではスレッドが再利用されるため、クリアしないと前リクエストの値が混入します。

@AsyncでのMDC引き継ぎ

@Async メソッドは別スレッドで動くため、デフォルトではMDCが引き継がれません。TaskDecorator を実装して ThreadPoolTaskExecutor に設定すると引き継ぎできます。

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> ctx = MDC.getCopyOfContextMap();
        return () -> {
            try {
                if (ctx != null) MDC.setContextMap(ctx);
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }
}

HandlerInterceptorでユーザーIDを付与する

FilterはServletコンテナレベルで動くのに対し、InterceptorはSpring MVCのDispatcherServlet以降で動くためSecurityContextにアクセスできます。FilterとInterceptorの使い分けはこちらの記事も参考にしてください。

@Component
public class MdcUserInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.isAuthenticated()) {
            MDC.put("userId", auth.getName());
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler, Exception ex) {
        MDC.remove("userId");
    }
}

@Component を付けるだけでは Spring MVC に登録されません。WebMvcConfigurer を実装したクラスで明示的に登録する必要があります。

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private MdcUserInterceptor mdcUserInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(mdcUserInterceptor);
    }
}

MDC.remove("userId")userId だけを削除しています。MDC.clear() はFilterのほうで行うので、ここでは特定キーのみ削除で十分です。

Spring Boot 3.4のネイティブ構造化ログ

Spring Boot 3.4からは追加ライブラリなしで構造化ログを有効化できます。

logging.structured.format.console=logstash

ecs(Elastic Common Schema)や graylog 形式も選べます。ただしlogstash-logback-encoderと比べてカスタマイズ性は限定的です。新規プロジェクトでシンプルに始めたい場合に試してみる価値はあります。

環境別にログフォーマットを切り替える

ローカルは人間可読なテキストログ、本番はJSONが実用的なパターンです。logback-spring.xml<springProfile> タグで切り替えられます。

<configuration>
    <springProfile name="prod">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <customFields>{"app":"my-service"}</customFields>
            </encoder>
        </appender>
        <root level="INFO"><appender-ref ref="CONSOLE"/></root>
    </springProfile>

    <springProfile name="!prod">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
            </encoder>
        </appender>
        <root level="DEBUG"><appender-ref ref="CONSOLE"/></root>
    </springProfile>
</configuration>

Spring Boot 3.4のネイティブ対応を使う場合は logback-spring.xml は不要です。application-prod.properties に以下を記述するだけで本番環境でJSONログが有効になります。

logging.structured.format.console=logstash

出力JSONの確認

FilterとInterceptorを組み合わせた場合の実際の出力です。

{
  "@timestamp": "2026-04-04T12:34:56.789Z",
  "level": "INFO",
  "message": "Order created successfully",
  "logger_name": "com.example.OrderService",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "userId": "user-123",
  "app": "my-service"
}

CloudWatch Logs Insightsなら fields @timestamp, requestId, userId, message | filter requestId = "550e8400-e29b-41d4-a716-446655440000" で特定リクエストのログを一発で絞り込めます。ElasticsearchのKibanaでは requestId フィールドがインデックスに自動的に入るのでフィールド検索でそのまま使えます。

ログ収集エージェント(CloudWatch AgentやFluent Bit等)の設定はインフラ側の作業になりますが、アプリがJSONを出力さえしていれば収集側の設定はかなりシンプルになります。

まとめ

JSON構造化ログの導入はそれほど難しくありません。logstash-logback-encoderを追加してlogback-spring.xmlを設定し、OncePerRequestFilterでMDCにリクエストIDをセットするだけで、ElasticsearchやCloudWatch Logsでのログ検索が格段に楽になります。

Spring Boot 3.4以降なら logging.structured.format.console=logstash の1行でも始められます。まずシンプルな構成で試して、カスタマイズが必要になったらlogstash-logback-encoderに切り替えるのもありです。

Logbackの基本設定がまだの方はそちらも合わせて確認してください。JSON構造化ログとMicrometerメトリクスを組み合わせた観測可能性の全体像はこちらを、分散トレーシングまで含めた内容はこちらの記事を参照してください。