Railsログ基盤をCloudWatch LogsからS3 + Athenaへ移行した話

# Railsログ基盤をCloudWatch LogsからS3 + Athenaへ移行した話

Railsアプリケーションのログ基盤を、CloudWatch LogsからS3 + Athenaに移行しました。本構成は現在ステージングと本番環境の両方に適用済みです。弊社ではAmazonのマネージドサービスを活用しつつ、Terraformでインフラを構築・管理しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215937/0525d763-8dfb-4fce-a38f-1ccac3afdf0f.png)



## 背景と目的

本ログ基盤の刷新は、以下の目的に基づいています：

- プロダクト改善施策の立案に必要な傾向分析
- システム構成やワーカー数の最適化に向けた実測データの把握
- 利用状況の可視化に基づくコスト見積もりやリソース配分
- AI活用を見据えたログデータセットの整備と統一フォーマット化

これらをコスト効率よく実現するため、CloudWatch Log Insightsベースの構成からS3 + Athenaを活用した構成へ移行しました。

---

## 全体構成

ログの流れは以下の通りです：

```
CloudWatch Logs
 → Subscription Filter
   → Kinesis Data Firehose (+ Glue Data Catalog)
     → S3 (Parquet形式)
       → Athena
```


CloudWatch Logsに集約されたアプリケーションの標準出力から、Railsの構造化ログのみをSubscription Filterで抽出し、Firehose経由でS3に転送・保存しています。

---

## 検討したが導入しなかったもの

### FireLens

FireLens（Fluent Bit）を使ってログを直接Firehoseへ転送する構成も検討しましたが、以下の理由から見送りました：

- Fluent Bitのconf記述に関する学習コストが高く、運用負荷を避けたかった
- WAFをすり抜けるPOST Flood攻撃などに即時対応するのに、CloudWatch Logsによる即時可視化が必要だった
- 冗長性の観点から、Firehose障害時にもCloudWatchにログが残る構成の方が望ましい

参考：
- [FireLens って結局なんだっけ？どう使えばいいの？](https://zenn.dev/mpyw/scraps/5c299399a6d8be)
- [AWS ECS on Fargate \+ FireLens で大きなログが扱いやすくなった話 \| BLOG \- DeNA Engineering](https://engineering.dena.com/blog/2022/08/firelens/)

### Embedded Metrics Format
カスタムメトリクスを取る際、アプリ側のAPI呼び出しに付随するレイテンシや非同期処理の実装を避ける目的でEmbedded Metrics Formatの導入も検討しました。  
しかし最終的にすべてのログをCloudWatch Logs経由にしたため、CloudWatch Logsのメトリクスフィルター機能を活用する方が構成としてシンプルで明快と判断しました。

参考：
- [CloudWatch Logsにカスタムメトリクスを埋め込める、Embedded Metricsが追加されました！ \| DevelopersIO](https://dev.classmethod.jp/articles/cloudwatch-logs-embedded-metrics/)

---

## Railsログの整備と活用

### ログの構造化と秘匿情報のフィルタリング

RailsのログをLogrageでJSON形式に変換し、Athenaで扱いやすい形に整備しています。特にPOSTパラメータに含まれる長文や秘匿情報に対しては、トランケート処理とフィルタリングを行っています。

```ruby
config.lograge.custom_options = ->(event) do
  exceptions = %i[controller action format authenticity_token]
  {
    datetime: Time.current.strftime('%F %T'),
    params: ActiveSupport::ParameterFilter.new(
      config.filter_parameters +
      [->(_k, v) { v.replace(v.truncate(256, omission: '...')) if v.is_a?(String) }]
    ).filter(event.payload[:params].except(*exceptions)),
    request_queue_time_ms: event.payload[:request_queue_time_ms],
    bot: !!event.payload[:bot]
  }
end
```

参考：
- [LogrageでRailsログをJSON整形](https://github.com/roidrage/lograge)

### Pumaのキュー時間（request_queue_time_ms）の記録と可視化

Railsのコントローラで`X-Request-Start`ヘッダーを元にPumaのキュー時間を計算し、ログに含めています。これはCloudWatch Logs上でメトリクスフィルターを使い、可視化・監視可能としています。
Railsパフォーマンスアポクリファでpumaのワーカー数を決定するための指標として紹介されていたため取得するようにしました。

```ruby
def set_request_queue_time
  x_request_start = request.headers['X-Request-Start']
  if x_request_start
    start_time = Time.at(x_request_start.sub('t=', '').to_f / 1000)
    request.env['request_queue_time_ms'] = ((Time.current - start_time) * 1000).round(2)
  end
end
```

```terraform
resource "aws_cloudwatch_log_metric_filter" "request_queue_time" {
  name           = "RequestQueueTimeMs"
  log_group_name = aws_cloudwatch_log_group.web_app.name
  pattern        = "{ $.request_queue_time_ms = * }"

  metric_transformation {
    name      = "RequestQueueTimeMs"
    namespace = "ServiceName/Staging"
    value     = "$.request_queue_time_ms"
    unit      = "Milliseconds"
  }
}
```

参考：
- [メトリクスフィルター、サブスクリプションフィルター、フィルターログイベント、およびライブテールのフィルターパターン構文 \- Amazon CloudWatch Logs](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html)
- [Ruby on Rails パフォーマンス アポクリファを読んで育休明けにやりたくなったこと \#パフォーマンスチューニング \- Qiita](https://qiita.com/IYD37/items/c02d1ef669acf5ead7e5)

---

## AWS側での設計ポイント

### Firehose受信時刻でのパーティション化

CloudWatch LogsからのSubscription Filter経由で届くログは圧縮されたDataMessage形式のため、FirehoseのMetadata Extractionを使ってログ内のtimestampでパーティションを切ることができません。そのため、Firehose受信時のUTCを使ってパーティションを作成しました。

参考：
- [Amazon Athena Partition Projectionを用いたHiveパーティションのパーティションプルーニングの自動化](https://dev.classmethod.jp/articles/20200727-amazon-athena-partition-projection-for-hive-partition/)

```terraform
resource "aws_glue_catalog_table" "rails_log" {
  name          = "rails_log"
  database_name = aws_glue_catalog_database.log.name
  table_type    = "EXTERNAL_TABLE"

  parameters = {
    "projection.enabled"        = "true"
    "projection.year.type"      = "integer"
    "projection.year.range"     = "2023,2030"
    "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://${aws_s3_bucket.logs.bucket}/rails/year=$${year}/month=$${month}/day=$${day}/"
  }

  storage_descriptor {
    location      = "s3://${aws_s3_bucket.logs.bucket}/rails/"
    input_format  = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetInputFormat"
    output_format = "org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat"

    serde_info {
      serialization_library = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"
    }
  }
}
```

### Firehoseのバッファ設定とParquet化

Athenaの推奨に従い、Firehoseのバッファサイズは128MB、間隔は15分に設定。Parquet形式で保存することで、クエリ時の読み取り効率と圧縮効率を高め、クエリコストも抑制しています。

参考：
- [Top 10 Performance Tuning Tips for Athena](https://aws.amazon.com/jp/blogs/news/top-10-performance-tuning-tips-for-amazon-athena/)

### S3バケットの分離とIAM設計

RailsログとAthenaクエリ結果を格納するS3バケットを分離することで、サービス本体からのアクセス権限（読み取り専用）を明確に制御できるようになり、IAMポリシーの設計が簡素化されました。

---

## 詰めきれなかったところ

- Firehoseの変換エラーはCloudWatch Logsにしか出力されないため、CloudWatch Alarmを組んで通知する運用が必要
- ログ内のdatetimeはJSTである一方、パーティションはUTC基準のため、Athenaで絞り込みを行う場合はパーティションを1日広めに取る必要がある


## 今後やりたいこと

- QuickSightを使用したビジネス側のダッシュボード整備
- サービス利用者へのアナリティクス機能提供
- Firehoseやその他AWSリソース側のエラー通知

## おわりに

CloudWatch LogsからAthenaへ移行するにあたって、構成はシンプルながらも、細かい仕様や制約に向き合う必要がありました。

運用開始後は、Athenaを用いたアクセス傾向分析や、API利用実態の調査・コスト見積もりに既に活用されており、移行の価値は十分にあったと実感しています。