every Tech Blog

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

エンジニアリングにおける生成AI活用の現在地

タイトル

株式会社エブリーでCTOを務めている今井(@imakei)です。

今回は、弊社で2ヶ月前に導入したCursorの成果についてお話しします。
結論から言うと、Pull Request数が2倍に増加するという、予想を上回る成果が出ています。

Cursorの導入とその背景

弊社エブリーは「明るい変化の積み重なる暮らしを、誰にでも」をパーパスに掲げ、レシピ動画メディア「デリッシュキッチン」などのサービスを展開しています。AIファーストカンパニーとして、プロダクトでのAI活用はもちろん、開発現場でもAIの実用的な活用を進めています。

その中で、下記のような価値実現を目指し、Cursorを導入しました。

  1. 本質的な価値創造への集中
    AIによる高度なコーディング支援によって実装にかかる時間を短縮することで、エンジニアおよびPdMがより創造的で本質的な業務に注力できる環境を構築。より早く高品質なサービスが提供できるようになることで、ユーザー体験の向上を加速する。

  2. プロダクト開発に関わる業務全体の生産性向上
    エブリー開発部では、開発生産性10倍を目標としており、「Cursor」の導入でこれら全ての業務における効率化を促進し、組織全体の生産性向上を図る。開発生産性とは、単にプログラムを実装するだけでなく、機能のアイデア創出、企画立案、仕様策定、テストなど多岐にわたる業務を含む、プロダクト開発全般に対する生産性の向上を目指す。

  3. AI活用を前提とした組織文化の醸成とイノベーションの推進
    今後、あらゆるプロダクトにおいてAIの活用が不可欠になると予測される中、エブリーでは、日々の業務でAIに触れる機会を創出することで、AIを当たり前に使いこなす組織文化を醸成。これにより、AIを活用した新たな価値創造を主導し、イノベーションを推進する。

数値が示す明確な変化:Pull Request数が2倍に増加

導入から2ヶ月が経過し、GitHubにて1人あたりのPull Request数が約2倍に増加しました。

エンジニア一人当たりのPR数の推移

上記は、エンジニア一人当たりのPR数ですが、Cursor導入以降、かなりの勢いでPR数が増えてるのがわかると思います。 細かい部分でCursorのrulesのためのPR数も含まれていたり、6月以降はDevinも導入していたりするので、 PRの種類や質など、厳密には比較できない部分もありますが、それを差し引いても大きく伸びたと言えそうです。

この数字の背景を自分なりに分析してみました。

1. 純粋な開発速度の向上.

「30分かかっていたユニットテストの作成が5分で完了するようになった」

このような声を頻繁に聞くようになりました。 決まったタスクについては、AIによって開発速度が大幅に向上したといえます。

2. AIを活用していくという雰囲気・文化の醸成

会社としてCursorの導入を決めたことで、AIを活用していく雰囲気が組織全体に醸成されています。 今までは、AIを活用していても個人レベルにとどまっていましたが、Cursor導入後は積極的に知見を共有するようになり、AI活用のレベルが組織全体で少しずつ向上してきました。

また最近では、有志でAI活用の勉強会を開催するなど、AI活用がより活発になってきています。

3. ドキュメントの整備

AI活用を進める中で予想外の効果として、ドキュメントの整備が進んでいます。

AIと効果的に協働するには、暗黙知になっていたコンテキストをいかにAIに伝えるかが重要になってきます。それに気づいたチームから、自発的にドキュメントの整備が始まっています。プロジェクトの背景、設計意図、実装の詳細など、これまで口頭で共有されていた情報のドキュメント化が進んでいます。

これは単にAI活用のためだけでなく、新メンバーのオンボーディング改善にもつながっています。AIに説明できるレベルで文書化することで、人間にとってもわかりやすいドキュメントが生まれたのです。

開発生産性10倍を目指して

現在、私たちは「開発生産性10倍」という野心的な目標を掲げています。これはコーディングアシストだけでなく、要件定義や仕様策定といった上流工程にもAIを活用することで実現しようとしています。

今後は、以下のような領域からまずはAI活用を進めていく予定です:

  • 要件定義・仕様策定:ユーザーストーリーの整理や仕様書の下書き作成
  • テスト設計:テストケースの網羅的な洗い出しと自動生成
  • プロジェクト管理:工数見積もりやリスク分析の支援
  • コードレビュー:より高度な設計レビューとベストプラクティス提案

「開発生産性10倍」は決して簡単な目標ではありませんが、AI活用によってエンジニアがより創造的で本質的な業務に集中できる環境を作り続けていきたいと思います。


生成AIでプロダクト開発をアップデートしたい方、 生成AIを活用したプロダクトを作りたい方、 ぜひ弊社で一緒に働きましょう!

PHPカンファレンス2025 最速参加レポート

去年に引き続き、エブリーは2025年6月28日(土)に大田区産業プラザPiOで開催されたPHPカンファレンス2025に参加させていただきました。

今回も参加レポートとして、会場の様子やセッションの感想についてお届けします!

イベント概要

https://phpcon.php.gr.jp/2025/

PHPカンファレンスは、PHP関連の技術を主とした技術者カンファレンスです。 2000年に日本のユーザ会によってPHPカンファレンスが初めて行われ、今年で26回目の開催となります。 これからPHPをはじめる方から、さらにPHPを極めていきたい方まで幅広く楽しめるイベントになるよう様々なプログラムをご用意しております。

セッションの感想

PHPの今とこれから2025 〜30周年を迎えたPHPと最新動向〜

2025年、PHPはついに誕生から30周年を迎えました。そして、PHPカンファレンスも今年で第25回。節目の年にふさわしく、「PHPの今とこれから」をテーマに、歴史と最新動向を振り返るセッションが展開されました。
その内容を簡単にお伝えできればと思います。

PHPの誕生とその時代背景

PHPが誕生したのは1995年。当時のインターネット黎明期には、いくつもの革新的な技術が登場しています。

  • 1993年4月30日:CERNがWWWを無償公開
  • 1994年12月15日:Netscape Navigator(初の一般向けWebブラウザ)リリース
  • 1995年1月21日:Apache HTTP Server誕生
  • 1995年5月23日:MySQL 1.0 リリース
  • 1995年6月8日:PHP 1.0 登場

つまり、Webそのものの始まりと共にPHPも誕生したと言えます。初期はPerlの代替のような簡易スクリプトとしてスタートしたPHPですが、
その後の5.x系の成熟、7.x系の大幅な高速化などを経て、現在の堅牢で高機能な言語へと進化してきました。

PHPの現状と普及率

現在のPHPは、サーバーサイド言語として依然として圧倒的なシェア約74%に上るようです。
バージョンの利用状況を見ても、7.x系と8.x系がそれぞれ約4割ずつと拮抗していますが、 依然として古いバージョンを使い続けているシステムも存在していました。
そして特に印象的だったのが、最新のPHP 8.4の利用率がまだ1%未満という点。
多くのシステムがまだバージョンアップに慎重な姿勢をとっていることがうかがえます。

11月20日リリース予定のPHP 8.5

2025年11月20日に正式リリース予定のPHP 8.5の新機能にもフォーカスが当てられました。

パフォーマンス

8.4とほぼ同等のパフォーマンスですが、環境によっては7%ほど改善されている例もあるようです。

主な新機能(一部抜粋)

  • パイプ演算子
    シェルのように値を次々にパイプ「|>」で渡して処理できる構文。コードの可読性と柔軟性が向上。
  • WHATWG URL・RFC3986準拠のURI生成
    セキュリティや整合性を強化する目的で、標準仕様に沿ったURL/URIオブジェクトを生成可能に。
  • クローン時にプロパティを指定可能:clone($x, $options)
  • NoDiscardアトリビュート
    呼び出し結果を無視すべきでないことを明示。

ライフサイクルとバージョンアップの重要性

PHPは現在、年1回のメジャーバージョンアップサイクルを採用しており、サポート期間は4年(バグ修正2年+セキュリティ修正2年)とされています。
最新機能を利用するためだけでなく、セキュリティサポートを継続的に受けるためにも定期的なバージョンアップは不可欠です。

2025年のPHPカンファレンスは、バージョンアップのお話だけでなく、今までの歴史や最新のシェア状況などのお話もあり大変面白いセッションでした。

PHP初心者セッション2025 〜ChatGPTと学ぶ、新時代のPHP入門〜

今年のPHPカンファレンスでは、初心者向けのセッション「ChatGPTと学ぶPHP入門」が開催されました。
開催前に挙手によるアンケートを行ったのですが、 参加者の8割がPHP初心者、中にはプログラム初心者もいるということで、これからPHPを始める人の多さに驚かされました。

このセッションのゴールは、ChatGPTを活用しながら、PHPで簡単なWebアプリを作ること。
はじめにPHPの基本文法や環境構築、変数や配列、条件分岐などの基礎が説明され、後半ではCSV出力のミニアプリをChatGPTと一緒に作成していきました。

印象的だったのは、「AIは主役ではなく相棒。開発を加速させる壁打ち役」という考え方。
エラー調査では「何をしたか」「どんなエラーが出たか」「どう解決したいか」をセットでAIに伝えると、より正確な回答が得られるというテクニックも紹介されました。

また、マニュアルを読むよりもAIに初心者向けに説明させる方が理解しやすい場面も多いとのこと。
セキュリティチェックやコードレビューにもAIは活用でき、今後はAIを使いこなすスキル自体が重要になっていくという話もありました。

AIと共に学ぶ新しいPHPの入口として、非常に実践的かつ未来を感じさせるセッションでした。

スポンサーブースの紹介

エブリーでは、 デリッシュキッチン に代表される様々なサービスを開発・運用しております。
その中でも小売業者向けのデータ連携サービス retail HUB では開発・運用にPHPを活用しています。
retail HUBは、小売業のDXを推進するサービスです。詳細はこちらをご覧ください!

PHPを活用する中で多くの恩恵を受けている私たちも、コミュニティのさらなる盛り上がりに貢献するため、スポンサーとして協賛させていただき、ブースを出展しました。

「なぜエブリーが協賛しているのか?」については、こちらのブログ記事もご覧ください!
PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!

ブース

エブリーでは、今回も弊社が提供するデリッシュキッチンのサービスをイメージしたブースの雰囲気を作りました。
多くの方にデリッシュキッチンを知っていると言っていただけてとても嬉しいと思いました。
デリッシュキッチンは知っているけど、エブリーという会社名は知らないという方も数多くいらっしゃいました。
今回のブース出展を機により多くの方にエブリーという会社名も認知していただければ嬉しいです!

ノベルティ

今回もデリッシュキッチンにちなんだノベルティを用意させていただきました。

  • ステッカー
  • CTOブレンドのコーヒーバッグ
  • デリッシュキッチンお料理グッズ

デリッシュキッチンお料理グッズに関してはXフォローでの抽選プレゼントキャンペーンを行いました。
当選した方の中には「めちゃめちゃ料理するのでたくさん使います!」と言っていただけてこちらも嬉しい気持ちになりました。

アンケート

今回、アンケートでは『使用しているAIツール』について回答していただきました。
GitHub CopilotとChatGPTが最も投票数を集める結果となりました。
稟議の関係でGitHub Copilotの導入が最もスムーズに進んだという方、様々なAIツールを並行利用して現在評価中という方など、
会社によって使っているAIツールは様々であることがわかり、とても興味深いアンケートとなりました。

ちなみにエブリーでは全エンジニアおよびプロダクトマネージャーが「Cursor」を導入しています。
詳しくはこちらの記事をご覧ください。
エブリー、AIエディタ「Cursor」を全エンジニアおよびプロダクトマネージャーに導入

ご回答いただいた皆様、ご協力いただきまして本当にありがとうございました!

各社スポンサーブースの様子

今年も各社ともに個性あふれるブースを展開しており、会場には活気が溢れていました。
クイズやアンケートパネル、さらにはコードレビュー体験など、多彩な企画が用意されていて、来場者が楽しみながら参加できる雰囲気でした。

スタンプラリー企画

まとめ

PHPカンファレンス2025の運営の皆様、そしてご参加された皆様、今年も本当にありがとうございました!

昨年の開催から半年しか経っていないにもかかわらず、PHP 8.5の最新情報や今後の進化の話題も多く、毎年確実に進化を続けているPHPに、改めてワクワクしました。

また、これまではPHP歴の長い方がメインの参加者なのかと思っていましたが、「初心者向け」と書かれているセッションでは、「PHPどころかプログラミング自体が初めてです!」という方も多く参加されていて、本当に幅広い層が集う、まさに“初心者から上級者まで”楽しめる素晴らしいコミュニティだと実感しました。

私たちも今回得られた知見を活かし、PHP 8.5の新機能を活用したプロジェクトにもどんどん取り組んでいきたいと思います。
今後もこうしたイベントや勉強会に積極的に参加し、PHPコミュニティの一員として、引き続きPHPの進化を追いかけていきます!

AWSを活用したスケーラブルなログ収集基盤の構築

はじめに

こんにちは。 開発本部 開発1部 デリッシュリサーチチームでデータエンジニアをしている吉田です。

エブリーではサイネージ端末を利用した広告配信サービスを提供しており、サイネージ端末からのログを収集しています。
従来はTreasureData-SDKを利用して端末からTreasureData上のテーブルにログを送信していましたが、ログ収集基盤を移行することになりました。
今回、AWSのマネージドサービスを活用して、スケーラブルなログ収集基盤を構築したので、その内容を紹介します。

背景

従来のログ収集基盤では、TreasureData-SDKを利用して端末からTreasureData上のテーブルにログを送信していました。
これはコストや処理の面で非常に便利でしたが、ログの送信がSDKに依存しているため、仮にTreasureData以外のログ収集基盤へ移行する場合には、アプリケーションのコードを変更する必要がありました。
サイネージ端末の都合上、アプリケーションのアップデートを極力行わずにログ収集基盤を変更できるようにしたい、という要望がありました。

そこで、アプリケーションの改修をすることなく、バックエンドをいつでも自由に変更できる、より柔軟なログ収集基盤の構築を目指しました。

アーキテクチャ

新しく構築したログ収集基盤は以下のコンポーネントで構成されています。

  1. API Gateway: アプリケーションからのログ送信用エンドポイント
  2. SQS: ログメッセージをバッファリング
  3. Lambda: SQSからメッセージを取得し、処理を行う
  4. Firehose: 処理されたログをS3に配信
  5. S3: ログの長期保存

この構成により、以下のメリットを実現しています:

  • スケーラビリティ: 突発的な大量のログにも対応可能
  • 信頼性: SQS によるメッセージの永続化とデッドレターキューによる失敗処理
  • 運用負荷の軽減: マネージドサービスの活用

全体のアーキテクチャは以下のようになります。

アーキテクチャ

コスト面では、リクエスト数に応じて課金されるAPI Gatewayの料金が、この基盤全体の大部分を占めることになります。
しかし今回は、そのコストよりも、将来的にログ収集基盤を柔軟に変更できるというメリットを重視し、この構成を採用しました。

詳細

API Gateway

API Gatewayはアプリケーションからログを受け取るエンドポイントを提供するとともに、SQSにメッセージを送信する役割を担います。
API Gateway から SQS へのインテグレーションでは、マッピングテンプレートを使用して、受信したJSONペイロードを SQS メッセージ形式に変換します。

terraformのコードは以下のようになります。

resource "aws_api_gateway_integration" "logs_post_integration" {
  rest_api_id             = aws_api_gateway_rest_api.this.id
  resource_id             = aws_api_gateway_resource.logs.id
  http_method             = aws_api_gateway_method.logs_post.http_method
  integration_http_method = "POST"
  credentials             = aws_iam_role.api_gateway.arn

  type = "AWS"
  uri  = "arn:aws:apigateway:${var.aws_region}:sqs:path/${var.aws_account_id}/${aws_sqs_queue.primary.name}"

  request_parameters = {
    "integration.request.header.Content-Type" = "'application/x-www-form-urlencoded'"
  }

  request_templates = {
    "application/json" = "Action=SendMessage&MessageBody=$util.urlEncode($input.body)"
  }

  passthrough_behavior = "WHEN_NO_TEMPLATES"
}

SQS

SQSはログメッセージをバッファリングし、Lambda関数による処理を待ちます。
API GatewayとLambdaの間にSQSを挟むことで、Lambdaの処理が失敗した場合でもメッセージを保持し、後で再処理できるようになります。
また、SQSのデッドレターキューを設定し、指定回数処理に失敗したメッセージを別のキューへ送信して処理させることで、エラーの確認を容易にするとともに、ログの喪失を防ぎます。

terraformのコードは以下のようになります。

resource "aws_sqs_queue" "dead_letter" {}

resource "aws_sqs_queue" "primary" {
  name = "${var.prefix}-primary-queue"

  delay_seconds              = 0                # 配信遅延秒
  visibility_timeout_seconds = 30               # メッセージの可視性タイムアウト秒
  max_message_size           = 262144           # 256KB
  message_retention_seconds  = 3 * 24 * 60 * 60 # メッセージの保持期間 (最大14日)
  receive_wait_time_seconds  = 20               # メッセージ受信待機時間秒

  sqs_managed_sse_enabled = true

  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.dead_letter.arn
    maxReceiveCount     = 3 # メッセージがDLQに移動するまでの最大受信回数
  })
}

resource "aws_sqs_queue_redrive_allow_policy" "this" {
  queue_url = aws_sqs_queue.dead_letter.id

  redrive_allow_policy = jsonencode({
    redrivePermission = "byQueue",
    sourceQueueArns   = [aws_sqs_queue.primary.arn]
  })
}

redrive_policyは、メッセージがデッドレターキューに移動する条件を設定します。
今回は最大3回の受信失敗でデッドレターキューに移動するように設定しています。

Lambda

Lambda関数はSQSからメッセージを取得し、処理を行います。
トリガーとしてSQSを設定し、メッセージがキューに追加されると自動的に起動します。

terraformのコードは以下のようになります。

resource "aws_lambda_function" "primary" {
  function_name = "${var.prefix}-log-sender"
  role          = aws_iam_role.lambda.arn
  filename      = data.archive_file.dummy.output_path

  architectures = ["arm64"]
  handler       = "main.lambda_handler"
  runtime       = "python3.13"
  memory_size   = 128 # MB
  timeout       = 5   # 秒

  logging_config {
    log_format            = "JSON"
    system_log_level      = "WARN"
    application_log_level = "INFO"
  }

  lifecycle {
    # 環境変数の変更を無視する
    # 別リポジトリで管理
    ignore_changes = [
      environment,
    ]
  }
}

resource "aws_lambda_event_source_mapping" "primary" {
  event_source_arn                   = aws_sqs_queue.primary.arn
  function_name                      = aws_lambda_function.primary.arn
  enabled                            = false
  batch_size                         = 1
  maximum_batching_window_in_seconds = 0

  function_response_types = ["ReportBatchItemFailures"]

  scaling_config {
    maximum_concurrency = 500
  }

  lifecycle {
    ignore_changes = [
      # enabledを無視する
      # コードデプロイ後に手動で有効化する
      enabled,
    ]
  }
}

aws_lambda_event_source_mappingのfunction_response_typesでReportBatchItemFailuresを指定しているため、Lambda関数で処理に失敗したメッセージだけがSQSに戻されます。
上記のTerraformではbatch_size=1と設定しているため、この設定は現状では効果がありませんが、将来的にbatch_sizeを変更する可能性を考慮して、このように設定しています。

Data Firehose

FirehoseはLambdaからデータを受け取り、S3に配信します。
Lambdaから直接S3に書き込む場合、S3のAPI呼び出しコストやマイクロファイル(小さなファイル)が多数生成されるといった問題が発生します。
特に、マイクロファイルが大量に存在すると、後段でデータ処理をする際のパフォーマンスが著しく低下する原因となります。
Firehoseを利用するとバッファリング、データの圧縮、パーティショニング、S3への書き込みなどを自動で行ってくれるため、これらの問題を解決できます。

terraformのコードは以下のようになります。

resource "aws_kinesis_firehose_delivery_stream" "primary" {
  name        = "${var.prefix}-primary-stream"
  destination = "extended_s3"

  extended_s3_configuration {
    bucket_arn = aws_s3_bucket.this.arn
    role_arn   = aws_iam_role.firehose.arn

    buffering_size     = 128 # MB
    buffering_interval = 300 # 秒

    file_extension = ".json"

    # プレフィックスの設定
    prefix              = "logs/!{partitionKeyFromQuery:event_name}/!{partitionKeyFromQuery:year}/!{partitionKeyFromQuery:month}/!{partitionKeyFromQuery:day}/!{partitionKeyFromQuery:hour}/"
    error_output_prefix = "errors/!{firehose:error-output-type}/"

    dynamic_partitioning_configuration {
      enabled = true
    }

    processing_configuration {
      enabled = true
      processors {
        type = "MetadataExtraction"
        parameters {
          parameter_name  = "JsonParsingEngine"
          parameter_value = "JQ-1.6"
        }
        parameters {
          parameter_name  = "MetadataExtractionQuery"
          parameter_value = "{year:.time | gmtime | .[0] | tostring,month:.time | gmtime | (.[1] + 1) | (. | if . < 10 then \"0\" + tostring else tostring end),day:.time | gmtime | .[2] | (. | if . < 10 then \"0\" + tostring else tostring end),hour:.time | gmtime | .[3] | (. | if . < 10 then \"0\" + tostring else tostring end),event_name:.event_name}"
        }
      }
    }

    cloudwatch_logging_options {
      enabled         = true
      log_group_name  = aws_cloudwatch_log_group.firehose_primary.name
      log_stream_name = aws_cloudwatch_log_stream.firehose_primary.name
    }

    s3_backup_mode = "Disabled"
  }

  server_side_encryption {
    enabled  = true
    key_type = "AWS_OWNED_CMK"
  }

  depends_on = [
    aws_iam_role_policy_attachment.firehose
  ]
}

動的パーティショニングの設定により、ログ中に含まれるイベント名と時間を元にS3のプレフィックスを動的に生成します。
MetadataExtractionQueryでは、JQを利用してJSON形式のログデータからパーティション分割に必要なキー(イベント名、年、月、日、時)を抽出しています。
これにより、後続のETLなどで効率的にデータを処理できるようになります。

S3

S3はFirehoseによって配信されたログデータの長期保存先として機能します。
バージョニングを設定して意図しない削除に備えるとともに、ACLをプライベートに設定して外部からのアクセスを制限します。

Lambdaの実装

Lambda関数では、SQSから受け取ったメッセージを処理し、Firehoseに送信します。
以下に実装の一部を示します。

def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
    batch_item_failures: List[Dict[str, str]] = []
    record: Dict[str, Any]
    for record in event.get("Records", []):
        message_id: Optional[str] = record.get("messageId")
        try:
            message_body_str: Optional[str] = record.get("body")
            log_data: Dict[str, Any] = json.loads(message_body_str)
            # Send to Firehose
            firehose_success: bool = send_to_firehose(log_data)
            # その他の送信

            if not firehose_success:
                batch_item_failures.append({"itemIdentifier": message_id})
        except json.JSONDecodeError as e:
            batch_item_failures.append({"itemIdentifier": message_id})
            continue
        except Exception as e:
                batch_item_failures.append({"itemIdentifier": message_id})
            continue

    if batch_item_failures:
        return {"batchItemFailures": batch_item_failures}
    else:
        return {"batchItemFailures": []}

前述のLambdaのterraformコードでfunction_response_types = ["ReportBatchItemFailures"]としているため、関数の戻り値は{"batchItemFailures": [失敗したメッセージID]}とします。
これにより、複数メッセージを受け取った際に、失敗したメッセージのみをSQSに戻せます。
エラーとなるメッセージがない場合は、{"batchItemFailures": []}を返します。

Lambdaのデプロイ

Lambdaのコードデプロイにはlambrollを利用しています。
コードと環境変数を管理するリポジトリと、Terraformでインフラを管理するリポジトリを分離しています。
これにより、インフラの変更とアプリケーションコードの変更のライフサイクルを分け、それぞれが独立して安全にデプロイできる体制を整えています。

lambroll init --function-name {function-name} --download --profile {aws-profile}で既存のLambdaのコードと設定ファイルをダウンロードできます。
このプロジェクトでは、function.dev.jsonのようにfunction.{env}.jsonという形式で環境ごとの設定ファイルを管理しています。
コードやfunction.jsonの編集後、lambroll deploy --function=function.{env}.json --profile {aws-profile}でデプロイを行います。

まとめ

今回、AWSのマネージドサービスを活用して、スケーラブルなログ収集基盤を構築しました。
API Gatewayを利用することで、アプリケーションのアップデートを行うことなく、ログ収集基盤を変更できる柔軟性を持たせています。

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

おわりに

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

PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!

PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!
PHP Conference Japan 2025 にGOLDスポンサーとして今年も協賛します!

はじめに

 この度、株式会社エブリーは、2025 年 6 月 28 日(土)に開催される「PHPカンファレンス2025」に、ゴールドスポンサーとして昨年に続き今年も協賛することになりました!

phpcon.php.gr.jp

PHPカンファレンス とは?

 日本PHPユーザ会 PHPカンファレンス実行委員会が主催となって、2000年より年に一度開催されている日本最大のPHPのイベントです。

 WEBサーバにインストールされているシェア8割を超える人気言語のイベントとして、初心者から上級者まで幅広い層のWEB系エンジニアが参加します。

2024年のカンファレンス概況

参加者数 約1,150名
協賛企業数 55社
スポンサーセッション数 7セッション
セッション数 52セッション

2025開催プラン

会場 大田区産業プラザPiO
オンラインツール 後日簡易アーカイブを実施予定
セッション 最大並行5トラック程度※トラックは増減する可能性はあります
25~35セッション程度+LTを予定
タイムテーブルはこちら
コンテンツ ・基調講演
・公募セッション
・スポンサーセッション
・スタンプラリー(プレゼント抽選)
懇親会 実施予定(有料)

イベントの参加申し込みはこちらで行っておりますのでぜひ会場でお会いしましょう

昨年の様子など

イベント当日について

当日は大田区産業プラザPiOの1F大展示ホール会場の各スポンサーブースにて、毎年恒例のスタンプラリーが実施されます。

エブリーのブースでは弊社のXアカウントをフォローしていただいた皆様全員に当たるくじ引き景品もございますので、ぜひご興味のある方はお越しください!

ノベルティ
ノベルティ

最後までお読みいただき、ありがとうございました!