🎯

Terraform で設定! S3 Access Logs と Athena Partition Projection

に公開

はじめに

こんにちは!ナウキャストのけびんです。

先日 Amazon S3 のコスト削減に関する記事を書きました。この中でも触れたように、S3のコスト削減においては不要なデータを特定することが非常に重要です。そのために関係者に直接問い合わせるのも重要ですが、適切にログを取りそれを分析することで機械的に確認することができます。
今回は Terraform で S3 の Server Access Log を有効化し、 Athena で分析できるようにする方法を紹介します。
https://zenn.dev/finatext/articles/aws-cost-savings-s3-phase1

全体の概要

そんな複雑ではないですが以下のような構成になります

  • S3 source bucket
    • アクセスログを収集したい対象のバケット
  • S3 destination bucket
    • アクセスログを保存するバケット
  • Athena table
    • アクセスログを分析するためのテーブル
    • S3 source bucket ごとにテーブルを作る
    • Partition projection による最適化も行う

この構成によって、 Athena で特定の S3 バケットへのアクセス状況を簡単に分析することが可能になります。

S3 Access Log 周りの設定

設定概要

まず対象の S3 バケットに対し、 Access Log の出力を有効化させましょう。基本的には以下に従って有効化するだけです。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ServerLogs.html

Terraform では aws_s3_bucket_logging です。重要な設定は以下の通りです。

  • TargetBucket / TargetPrefix
    • ログをどこのバケットのどのパスに保存するかの設定
    • 後述の PartitionedPrefix の key format を使えばバケットごとにパーティションは切られるので、 TargetPrefix にソースバケットの情報を入れる必要はない
  • TargetObjectKeyFormat
    • どのようなフォーマットでログを保存するかを選ぶことができ、以下の2つの選択肢があるが、 PartitionedPrefix を利用する
    • SimplePrefix
      • [DestinationPrefix][YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString] の形式でログのオブジェクトが作られる
    • PartitionedPrefix
      • [DestinationPrefix][SourceAccountId]/[SourceRegion]/[SourceBucket]/[YYYY]/[MM]/[DD]/[YYYY]-[MM]-[DD]-[hh]-[mm]-[ss]-[UniqueString] の形式でログのオブジェクトが作られる
      • その名の通り、日付でパーティションが切られる方法で、この日付には以下の2つの選択肢がある
        • DeliveryTime : ファイル名の時刻がログファイルがデリバリーされた時刻となる
        • EventTime : 配信されるログは特定の日に発生したイベントのみを対象とし、キー内の年・月・日はイベントが発生した日付に対応し、時・分・秒はすべて 00 に設定されれる

注意点

この設定で S3 アクセスログの出力自体は完了ですが、いくつか注意点があります。

  • source bucket と destination bucket は 同一リージョンにある 必要がある
  • source bucket と destination bucket は 同一 AWS アカウントが所有している 必要がある
  • destination bucket ではログの出力は有効化すべきではない
    • 「ログ出力のログが出力される」という無限ループに陥ってしまうため
  • destination bucket でバケットポリシーを修正する必要がある
    • ロギングサービスのプリンシパル logging.s3.amazonaws.com に対し、 s3:PutObject の権限を付与する必要がある
  • ログの形式について
    • 実際に配信されるログの中身の形式についてはこちらに詳細が書かれている
    • 「アクセスポイントARN」など、 CloudTrail logs では見られないものも存在するため、適宜利用すると便利

これらに注意して有効化の設定の後、マネコンで source bucket にアクセスしてみましょう。比較的すぐに実際にログファイルが destination bucket の中に出力されるはずです。

Athena と Partition Projection

Partition について

ログを確認したり分析したりしやすくするために、 destination bucket の特定のパスに対し、 Athena table を作るようにしておきましょう。

ただし、S3 バケットへのアクセス回数はそれなりに多いことが予想されるので適当に Athena テーブルを作ってしまうと、 Athena 自体のコストも無視できなくなってしまいます。コスト削減のための調査で使う Athena のコストが高いと本末転倒ですね。そこで、適切に Partition を利用しましょう。Athena の Partition とは、大きいデータセットを複数の小さく管理しやすいサイズに分割してデータを保存・管理する手法のことで、日付やリージョンといった特定のカラムに基づき分割されることが多いです。 Partition を作成し、クエリの際にそのカラムでフィルタリングをすると、該当の Partition しかデータを読み込まないようになるため、クエリは高速になりますし、 Athena のコストも安くなります。

Hive 標準形式での Partition の作り方であれば、 MSCK REPAIR TABLE を実行することで新しい Partition を追加することが可能です。そうでない形式の場合は、後から Partition が増えた場合、都度 ALTER TABLE ADD PARTITION を実行する必要があります。これらによって実際のパーティションの値や情報はメタデータとして Glue カタログに登録されるわけです。

Partition Projection について

先ほどの例の場合、 [YYYY]/[MM]/[DD] の部分を Partition に利用すると便利ですが、これは Hive 形式ではないため ALTER TABLE ADD PARTITION を定期実行する必要があり、面倒です。このような場合には Partition Projection(パーティション射影) を利用しましょう。これは今回のケースのように、パーティションの形式が既知の場合に利用できるオプションです。パーティションのメタデータをメタデータに保存しておかなくても、都度計算して算出するのに必要な情報だけテーブルのプロパティに設定をしておきます。

以下のような設定を DDL として追記するイメージです。

CREATE EXTERNAL TABLE hoge
...
LOCATION "s3://bucket/prefix/"
PARTITIONED BY (
  datehour STRING
)
TABLE PROPERTIES (
  "projection.enabled" = "true",
  "projection.datehour.type" = "date",
  "projection.datehour.range" = "2018/01/01/00,NOW",
  "projection.datehour.format" = "yyyy/MM/dd/HH",
  "projection.datehour.interval" = "1",
  "projection.datehour.interval.unit" = "HOURS",
  "storage.location.template" = "s3://bucket/prefix/${datehour}"
  ...
)

Terraform では aws_glue_catalog_tableparameters に適宜同様な内容を追記します。

Terraform での実装例

それでは実際の Terraform での設定例を見ていきましょう。

data / local の定義

misc.tf
data "aws_caller_identity" "current" {}

locals {
  s3_access_log_prefix = "s3_access_logs_partitioned"
}

S3 source bucket

s3_source.tf
##########################################################################################
# コスト管理対象バケット
##########################################################################################
resource "aws_s3_bucket" "source_bucket" {
  bucket        = "source_bucket"
  force_destroy = false
}

resource "aws_s3_bucket_public_access_block" "source_bucket" {
  bucket = aws_s3_bucket.source_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_logging" "source_bucket" {
  bucket = aws_s3_bucket.source_bucket.id

  target_bucket = aws_s3_bucket.s3_access_log_bucket.id
  target_prefix = "${local.s3_access_log_prefix}/"

  target_object_key_format {
    partitioned_prefix {
      partition_date_source = "EventTime"
    }
  }
}

# その他必要な設定
# ...

S3 destination bucket

s3_destination.tf
##########################################################################################
# S3 Access Logs 保存バケット
##########################################################################################
resource "aws_s3_bucket" "s3_access_log_bucket" {
  bucket        = "s3-access-log-bucket"
  force_destroy = false
}

resource "aws_s3_bucket_public_access_block" "s3_access_log_bucket" {
  bucket = aws_s3_bucket.s3_access_log_bucket.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "s3_access_log_bucket" {
  bucket = aws_s3_bucket.s3_access_log_bucket.id

  versioning_configuration {
    status = "Disabled"
  }
}

resource "aws_s3_bucket_policy" "s3_access_log_bucket" {
  bucket = aws_s3_bucket.s3_access_log_bucket.id

  policy = jsonencode({
    "Version" : "2012-10-17",
    "Statement" : [
      {
        "Sid" : "S3ServerAccessLogsPolicy",
        "Effect" : "Allow",
        "Principal" : {
          "Service" : "logging.s3.amazonaws.com"
        },
        "Action" : [
          "s3:PutObject"
        ],
        "Resource" : "${aws_s3_bucket.s3_access_log_bucket.arn}/*",
        "Condition" : {
          "ArnLike" : {
            "aws:SourceArn" : [aws_s3_bucket.source_bucket.arn]
          },
          "StringEquals" : {
            "aws:SourceAccount" : data.aws_caller_identity.current.account_id
          }
        }
      }
    ]
  })
}

# その他必要な設定
# ...

Athena table

glue.tf
##########################################################################################
# S3 Access Logs 用の Athena Table
##########################################################################################
resource "aws_glue_catalog_database" "s3_access_logs_db" {
  name = "s3_access_logs_db"
}

resource "aws_glue_catalog_table" "source_bucket" {
  name          = "${aws_s3_bucket.source_bucket.id}_access_logs"
  database_name = aws_glue_catalog_database.s3_access_logs_db.name
  table_type    = "EXTERNAL_TABLE"

  storage_descriptor {
    location      = "s3://${aws_s3_bucket.s3_access_log_bucket.id}/${local.s3_access_log_prefix}/${data.aws_caller_identity.current.account_id}/${aws_s3_bucket.s3_access_log_bucket.region}/${aws_s3_bucket.source_bucket.id}/"
    input_format  = "org.apache.hadoop.mapred.TextInputFormat"
    output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"

    ser_de_info {
      name                  = "my-serde"
      serialization_library = "org.apache.hadoop.hive.serde2.RegexSerDe"
      parameters = {
        "input.regex" = "([^ ]*) ([^ ]*) \\[(.*?)\\] ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) (-|[0-9]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) (\"[^\"]*\"|-) ([^ ]*)(?: ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*) ([^ ]*))?.*$"
      }
    }

    columns {
      name = "bucketowner"
      type = "string"
    }
    columns {
      name = "bucket_name"
      type = "string"
    }
    columns {
      name = "requestdatetime"
      type = "string"
    }
    columns {
      name = "remoteip"
      type = "string"
    }
    columns {
      name = "requester"
      type = "string"
    }
    columns {
      name = "requestid"
      type = "string"
    }
    columns {
      name = "operation"
      type = "string"
    }
    columns {
      name = "key"
      type = "string"
    }
    columns {
      name = "request_uri"
      type = "string"
    }
    columns {
      name = "httpstatus"
      type = "string"
    }
    columns {
      name = "errorcode"
      type = "string"
    }
    columns {
      name = "bytessent"
      type = "bigint"
    }
    columns {
      name = "objectsize"
      type = "bigint"
    }
    columns {
      name = "totaltime"
      type = "string"
    }
    columns {
      name = "turnaroundtime"
      type = "string"
    }
    columns {
      name = "referrer"
      type = "string"
    }
    columns {
      name = "useragent"
      type = "string"
    }
    columns {
      name = "versionid"
      type = "string"
    }
    columns {
      name = "hostid"
      type = "string"
    }
    columns {
      name = "sigv"
      type = "string"
    }
    columns {
      name = "ciphersuite"
      type = "string"
    }
    columns {
      name = "authtype"
      type = "string"
    }
    columns {
      name = "endpoint"
      type = "string"
    }
    columns {
      name = "tlsversion"
      type = "string"
    }
    columns {
      name = "accesspointarn"
      type = "string"
    }
    columns {
      name = "aclrequired"
      type = "string"
    }
  }

  partition_keys {
    name = "event_date"
    type = "string"
  }

  parameters = {
    "projection.enabled"                  = "true"
    "projection.event_date.type"          = "date"
    "projection.event_date.format"        = "yyyy/MM/dd"
    "projection.event_date.interval"      = "1"
    "projection.event_date.interval.unit" = "DAYS"
    "projection.event_date.range"         = "2025/07/01,NOW"
    "storage.location.template"           = "s3://${aws_s3_bucket.s3_access_log_bucket.id}/${local.s3_access_log_prefix}/${data.aws_caller_identity.current.account_id}/${aws_s3_bucket.s3_access_log_bucket.region}/${aws_s3_bucket.source_bucket.id}/$${event_date}/"
  }
}

分析クエリの例

ここまでの設定により、

  • ソースバケットからデスティネーションバケットへのログの配信
  • Partition Projection の設定がされたバケットごとのアクセスログの Athena テーブル

ができました。 Athena で以下のようなクエリを実行することで、どこのパスへのアクセスがあるのかを確認することができます。この際に event_date を where 句で指定することで対象のソースバケットへ特定の日にアクセスしたログのみを取得する Partition Pruning が効くようになります。

select
    event_date,
    split_part(requester, ':', 5) as requester_account,
    httpstatus,
    errorcode,
    count(*) as count
from "s3_access_logs_db"."source_bucket_hoge"
where True
    and event_date between '2025/11/17' and '2025/11/22'
    and requester like '%012345678901%'
group by 1,2,3,4
order by 1,2,3,4
;

まとめ

S3 のストレージコストを削減するためにはまず現状を理解し、不要なデータを特定することが大事です。関係者にヒアリングして特定するという方法も重要ではありますが、今回のようにログ出力の設定をしておくことでアクセス頻度に基づく調査を進めやすくなるかと思います。
ぜひ皆さんも S3 Access Log を見てコスト最適化にトライしてみてください!

GitHubで編集を提案
Finatext Tech Blog

Discussion