はじめに
こんにちは、エブリーでサーバーサイドをメインに担当している清水です。
私のチームでは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
おわりに
最後までお読みいただきありがとうございました。
今回の作業をするにあたって多くの先駆者様の記事を参考にさせていただきました。
ログ出力する方法には様々な実装方法があり、どのような方法を採用するべきか悩みながら進めました。
この記事がこれから同じことをする人に少しでも参考になれば幸いです。