every Tech Blog

株式会社エブリーのTech Blogです。

LaravelでJSON形式のログを出力してfluentbit経由でS3にアップロードしてみた

はじめに

こんにちは、エブリーでサーバーサイドをメインに担当している清水です。

私のチームではPHP, Laravelを使用して小売店向けのSaaS型Webサービスの開発を行っています。インフラはAmazon ECS (Fargate)です。
このシステムではユーザーのアクションを分析するためのイベントログを出力する機能があり、こちらをAmazon S3に出力するためにfluentbitを使用することにしました。
当初の予定では1日程度で終わるはず思っていたのですが、思った以上にうまくいかず1週間ほどかかってしまいました。

本記事では、Laravel → 標準出力 → Fluent Bit → S3 → Athenaという構成で、イベントログを出力するまでのフローをコードレベルで丸ごと紹介しつつ、いくつかハマったポイントを共有します。
本記事を参考にとりあえず動く状態にしていただいて、足りない部分はカスタマイズしていただくような形で利用いただければ幸いです。

環境情報

  • PHP 8.3.22
  • Laravel 11.18.1
  • Fluent Bit 1.9.10

Laravelからイベントログを標準出力する機能を実装する

ある処理が呼び出された時のイベントログ出力処理例

    public function show(int $id)
    {
        Log::channel('event')->info('記事閲覧イベントログ', [
            'event_type' => 'show_article',
            'article_id' => $id,
        ]);
        // ※ログ以外の処理は省略
    }

イベントログ用のフォーマッターを実装

<?php

namespace App\Logging;

use Monolog\Formatter\NormalizerFormatter;
use Monolog\LogRecord;

class EventLogFormatter extends NormalizerFormatter
{
    public function format(LogRecord $record): string
    {
        $formatted = parent::format($record);
        $segments = [
            'level' => $formatted['level_name'],
            'datetime' => $formatted['datetime'],
            'env' => $formatted['channel'],
            'message' => $formatted['message'],
            'extra' => $formatted['extra'],
            'log_type' => 'EVENT',
        ];

        // コンテキストのすべてのキーを出力に含める
        foreach ($formatted['context'] as $key => $value) {
            $segments[$key] = $value;
        }

        return json_encode($segments, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES).PHP_EOL;
    }
}

※以下の記事を参考にさせていただきました。
Laravel(monolog)で構造化ログを実装する

config/logging.phpにeventチャネルを定義

        'event' => [
            'driver' => 'monolog',
            'level' => env('LOG_LEVEL', 'debug'),
            'handler' => StreamHandler::class,
            'formatter' => EventLogFormatter::class,
            'with' => [
                // Swooleを使用しているとstdoutの標準出力に余計な文字列がつけられるため、stderrとしました。
                'stream' => 'php://stderr',
            ],
        ],

ローカル開発環境にて標準出力でイベントログがJSON形式で出力されることを確認

{"level":"INFO","datetime":"2025-06-24T12:59:29.427634+09:00","env":"local","message":"記事閲覧イベントログ","extra":[],"log_type":"EVENT","event_type":"show_article","article_id":1}

fluentbitを設定する

Dockerfile

# ベースイメージとしてAWS公式Fluent Bitイメージを指定
FROM public.ecr.aws/aws-observability/aws-for-fluent-bit:stable

# 作業ディレクトリの作成
WORKDIR /fluent-bit/etc

# カスタムfluent-bit.confをイメージ内へコピー
COPY fluent-bit-custom.conf /fluent-bit/etc/fluent-bit-custom.conf

# カスタムパーサーファイルもコピー
COPY json-string.conf /fluent-bit/etc/json-string.conf

fluent-bit-custom.conf

[SERVICE]
    Parsers_File    /fluent-bit/etc/json-string.conf
    Flush           5
    Grace           30

# 空のログをフィルタリング
[FILTER]
    Name            grep
    Match           *-firelens-*
    Exclude         log ^$

# JSONパース用フィルター:カスタムパーサー json_string を使用
[FILTER]
    Name            parser
    Match           *-firelens-*
    Parser          json_string
    Key_Name        log
    Preserve_Key    Off
    Reserve_Data    Off

# event log のタグ付け
[FILTER]
    Name            rewrite_tag
    Match           *-firelens-*
    Rule            log_type ^EVENT$ s3.event.$event_type true

# EVENTログ出力
[OUTPUT]
    Name            s3
    Match           s3.event.*
    bucket          ${AWS_S3_LOG_BUCKET} # 環境変数で出力先S3バケット名を指定する形とする
    region          ap-northeast-1
    use_put_object  Off
    upload_timeout  60s
    compression     gzip
    s3_key_format   /EVENT/$TAG[2]/%Y/%m/%d/%H_%M_%S_$UUID.gz

json-string.conf

[PARSER]
    Name                json_string
    Format              json
    Time_Key            datetime
    Time_Keep           On
    Time_Format         %Y-%m-%dT%H:%M:%S.%L%z

→Dockerイメージをビルドして、latestタグでAmazon ECRにプッシュしておく

ECSタスク定義でfluentbitを使用する設定に変更する

   "containerDefinitions": [
        {
            "name": "{Webサービスの名前}",
            "image": "{イメージ名}",

            (省略)

            "logConfiguration": {
                "logDriver": "awsfirelens"
            }
        },
        {
            "name": "log_router",
            "image": "XXXXXXXXXX.dkr.ecr.ap-northeast-1.amazonaws.com/{fluentbitイメージ名}:latest",
            
            (省略)
            
            "environment": [
                {
                    "name": "AWS_S3_LOG_BUCKET",
                    "value": "{出力先となるS3バケット}"
                }
            ],

            "logConfiguration": {
                "logDriver": "awslogs",
                "options": {
                    "awslogs-group": "{log_routerのログ出力先}",
                    "awslogs-region": "ap-northeast-1",
                    "awslogs-stream-prefix": "ecs",
                    "awslogs-create-group": "true",
                    "mode": "non-blocking",
                    "max-buffer-size": "25m"
                }
            },

            "firelensConfiguration": {
                "type": "fluentbit",
                "options": {
                    "config-file-type": "file",
                    "config-file-value": "/fluent-bit/etc/fluent-bit-custom.conf"
                }
            }
        }

Athenaで外部テーブルを作成する

CREATE EXTERNAL TABLE IF NOT EXISTS show_article_event_logs (
  level       string,
  datetime    string,
  env         string,
  message     string,
  extra       array<string>,
  log_type    string,
  event_type  string,
  article_id  int
)
PARTITIONED BY (
  year  string,
  month string,
  day   string
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
LOCATION 's3://{バケット名}/EVENT/show_article/'
TBLPROPERTIES (
  'projection.enabled'       = 'true',
  'projection.year.type'     = 'integer',
  'projection.year.range'    = '2025,2100',
  'projection.year.digits'   = '4',
  'projection.month.type'    = 'integer',
  'projection.month.range'   = '1,12',
  'projection.month.digits'  = '2',
  'projection.day.type'      = 'integer',
  'projection.day.range'     = '1,31',
  'projection.day.digits'    = '2',
  'storage.location.template'=
    's3://{バケット名}/EVENT/show_article/${year}/${month}/${day}/'
);

引っかかった部分

ローカルでテストする方法がわからなかった

最初は一発で上手くいくだろうと楽観視していきなりAWS上にデプロイしてテストしてみましたが、当然上手くいきませんでした。
ローカルでどうやってダミーデータを標準出力させるか?どうやってfluentbitにその標準出力を認識させるのか?といったことを考えていたら手が止まりました。

ローカルのテスト手順はこちらの記事の通りに対応して上手くいきました。

Fluent Bitを導入しました:ローカル実行・確認方法と、導入の過程でハマったこと

application.conf にテストデータ投入用の[INPUT]、出力確認用の[OUTPUT]を追加します。

   Name dummy
   Tag  *-firelens-*
   Dummy {"date":"2022-01-23T03:10:33.317817Z","source":"stdout","log":"time:2022-01-23T03:10:33+00:00\tprotocol:HTTP/1.1\tstatus:200\tsize:1450\treqsize:150\treferer:-\treqtime:0.176\tcache:-\truntime:-\t"}

Dockerを起動してターミナルを開き、以下でFluent Bitを起動します。

/fluent-bit/bin/fluent-bit -c /fluent-bit/etc/application.conf

コンテナ内でAWSのアクセスキーをexportするだけでS3へのアップロード処理も問題なく実行されます

export AWS_ACCESS_KEY_ID="{AWSアクセスキー}"
export AWS_SECRET_ACCESS_KEY="{AWSシークレットアクセスキー}"

AthenaのSELECT実行結果が全カラム空白になってしまう

色々試した結果、ログファイルの拡張子が.gzになっていないと発生することがわかった

# before
    s3_key_format   /EVENT/$TAG[2]/%Y/%m/%d/%H_%M_%S_$UUID
# after
    s3_key_format   /EVENT/$TAG[2]/%Y/%m/%d/%H_%M_%S_$UUID.gz

おわりに

最後までお読みいただきありがとうございました。
今回の作業をするにあたって多くの先駆者様の記事を参考にさせていただきました。
ログ出力する方法には様々な実装方法があり、どのような方法を採用するべきか悩みながら進めました。
この記事がこれから同じことをする人に少しでも参考になれば幸いです。