Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

S3 Inventory + Athena によるプレフィックスレベルコスト分析 ~S3 のコストをスリムに保つために~

プロダクト開発部 Ads チームの yamaYu です。最近体重と筋肉量をコントロールしたい願望があり、まずは可視化ということで体組成計を買いました。まだ全然成果は上がっていないですが、現状を把握できて良い感じです。

今回の記事ですが、最近取り組んだ S3 のコスト削減の施策において、S3 Inventory がコストの可視化の文脈で良い感じだったのでその話について書いていきます。

最終的に ↓ のような感じでプレフィックス別にコストを分析できるようにしました。

プレフィックス別の S3 のコストの例

課題感

Ads チームの AWS のコストの内訳を見ると S3 のストレージ利用料金が大きな割合を占めています。

Ads チームの AWS コスト内訳

合計ストレージサイズは 2PB、オブジェクト数は 1G ありました。その大部分は長年蓄積された広告サービスのログによるものです。ログ用のバケットの中にサービスごとのプレフィックスが切られており、それぞれのサービスのログが格納されています。

コスト削減できる箇所を探して対応の優先順位をつけるために、どのプレフィックスでコストがかかっているのかを把握する必要があったのですが、プレフィックスの数が多く、階層の深さがプロジェクトごとに様々で、格納されているオブジェクトの数が膨大なため全容をすぐには把握できない状態でした。

継続的にコスト改善をしていくためにボトルネックをシュッと確認できるようにするための解決策が求められていました。

S3 のストレージ使用量を把握するための選択肢

S3 のストレージ使用量を把握するには、次のような選択肢があります。

  • AWS CLI
  • S3 Storage Lens
  • S3 Inventory

以下でそれぞれ見ていきます。なお価格に言及している箇所については 2024 年 4 月時点の東京リージョンの価格になります。

AWS CLI

一番シンプルな方法です。AWS CLI を叩きます。

aws s3 ls s3://my_bucket/some/prefix/ --sum --human --recursive

オブジェクト数が少ない場合や分析対象のプレフィックスが明確な場合にはこれで十分だと思います。オブジェクト数が多い場合には実行にそれなりのリクエスト料金と時間がかかるため注意が必要です。

ls — AWS CLI 2.15.33 Command Reference

S3 Storage Lens

S3 の使用量を可視化したダッシュボードを提供するサービスです。無料枠としてバケットレベルでの分析が提供されています。また有料オプションの高度なメトリクスの一つにプレフィックスレベルでの分析があり、ストレージ使用量上位のプレフィックスの一覧をダッシュボード上に表示できます。

有料オプションはオブジェクト数での課金体系になっており、100 万オブジェクトあたりの月額はオブジェクトサイズによって 0.12USD 〜 0.20USD です。

簡単に設定できる一方、表示件数に制限があり、ストレージクラスでフィルタできないなどあまり柔軟な使い方はできない印象です。

Amazon S3 Storage Lens の紹介 — オブジェクトストレージに組織全体にわたる可視性を | Amazon Web Services ブログ

S3 Inventory

S3 バケット内のオブジェクトの一覧とファイルサイズやストレージクラスなどのメタデータを CSV や Parquet などの形式で定期的に出力してくれるサービスで、この記事の本命です。

オブジェクト数での課金体系になっており、100 万オブジェクトあたりの実行単価は 0.0028USD なので、日次で実行したとしても S3 Storage Lens の有料オプションよりもこちらのほうが安いです*1

docs.aws.amazon.com

出力ファイルを Athena と連携すれば、SQL で分析することもできます。

docs.aws.amazon.com

S3 Inventory で使用量を可視化する

ストレージクラスやログの生成年でのフィルタなど柔軟な分析をするために S3 Inventory を採用しました。以下では、Terraform での設定と分析クエリの一例を解説します。

S3 Inventory を設定する

分析対象のバケットごとに S3 Inventory の設定していきます。下記は target-bucket バケットのオブジェクト一覧を output-bucket バケットに Parquet 形式*2で日次で出力する設定例です。optional_fields にはお好みで分析に必要なカラムを追加してください。例ではコスト計算に必要な sizestorage_class 以外は省略しています。

s3_bucket_inventory | Terraform Registry

// S3 Inventory 設定するバケット
resource "aws_s3_bucket" "target_bucket" {
  bucket = "target-bucket"
}

// S3 Inventory の出力先のバケット
resource "aws_s3_bucket" "output_bucket" {
  bucket = "output-bucket"
}

// S3 Inventory の設定
resource "aws_s3_bucket_inventory" "target_bucket" {
  bucket = aws_s3_bucket.target_bucket.id
  name   = "default"
  destination {
    bucket {
      format     = "Parquet"
      bucket_arn = aws_s3_bucket.target_bucket.arn
    }
  }
  schedule {
    frequency = "Daily"
  }
  included_object_versions = "All"
  optional_fields = [
    "Size",
    "StorageClass",
    ︙
  ]
}

また出力先バケットに S3 Inventory による書き込みの許可が必要なため、バケットポリシーを設定します*3

// 出力バケットにバケットポリシーを追加する
resource "aws_s3_bucket_policy" "s3inventory" {
  bucket = aws_s3_bucket.output_bucket.id
  policy = data.aws_iam_policy_document.allow_s3inventory_put_object_to_output_bucket.json
}

data "aws_iam_policy_document" "allow_s3inventory_put_object_to_output_bucket" {
    statement {
    sid    = "AllowS3InventoryPutObjectToOutputBucket"
    effect = "Allow"
    actions = [
      "s3:PutObject",
    ]
    condition {
      test     = "StringEquals"
      variable = "s3:x-amz-acl"
      values = [
        "bucket-owner-full-control",
      ]
    }
    condition {
      test     = "StringEquals"
      variable = "aws:SourceAccount"
      values = [
        "{your_aws_acount_id}",
      ]
    }
    resources = [
      aws_s3_bucket.output_bucket.arn,
    ]
    principals {
      type = "Service"
      identifiers = [
        "s3.amazonaws.com",
      ]
    }
  }
}

Athena と連携する

出力ファイルを Athena から読み込むために Glue Data Catalog のデータベースとテーブルを作成します。 パーティションキーに bucket_name を追加し、各バケットごとに設定した S3 Inventory の出力を一つのテーブルで一括で扱えるようにしています。

aws_glue_catalog_table | Terraform Registry

resource "aws_glue_catalog_database" "workspace" {
  name         = "workspace"
  location_uri = "s3://${aws_s3_bucket.output_bucket.id}"
}

resource "aws_glue_catalog_table" "s3inventory" {
  database_name = aws_glue_catalog_database.workspace.name
  name          = "s3inventory"
  table_type    = "EXTERNAL_TABLE"
  parameters = {
    "projection.enabled"          = "true"
    "projection.bucket_name.type" = "injected"
    "projection.dt.format"        = "yyyy-MM-dd-HH-mm"
    "projection.dt.interval"      = "1"
    "projection.dt.interval.unit" = "HOURS"
    "projection.dt.range"         = "2024-01-01-00-00,NOW"
    "projection.dt.type"          = "date"
    "storage.location.template"   = "${aws_glue_catalog_database.workspace.location_uri}/$${bucket_name}/default/hive/dt=$${dt}"
  }
  storage_descriptor {
    location      = aws_glue_catalog_database.workspace.location_uri
    input_format  = "org.apache.hadoop.hive.ql.io.SymlinkTextInputFormat"
    output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"
    ser_de_info {
      name                  = "ParquetHiveSerDe"
      serialization_library = "org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe"
    }
    columns {
      name    = "key"
      type    = "string"
    }
    columns {
      name    = "size"
      type    = "bigint"
    }
    columns {
      name    = "storage_class"
      type    = "string"
    }}
  partition_keys {
    name    = "bucket_name"
    type    = "string"
  }
  partition_keys {
    name    = "dt"
    type    = "string"
  }
}

SHOW CREATE TABLE workspace.s3inventory の出力も載せておきます。

CREATE EXTERNAL TABLE `workspace.s3inventory`(
  `key` string,
  `size` bigint,
  `storage_class` string,
  ︙
) PARTITIONED BY (
  `bucket_name` string,
  `dt` string
) ROW FORMAT SERDE
  'org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe'
STORED AS INPUTFORMAT
  'org.apache.hadoop.hive.ql.io.SymlinkTextInputFormat'
OUTPUTFORMAT
  'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION
  's3://workspace'
TBLPROPERTIES (
  'projection.bucket_name.type'='injected',
  'projection.dt.format'='yyyy-MM-dd-HH-mm',
  'projection.dt.interval'='1',
  'projection.dt.interval.unit'='HOURS',
  'projection.dt.range'='2024-01-01-00-00,NOW',
  'projection.dt.type'='date',
  'projection.enabled'='true',
  'storage.location.template'='s3://workspace/${bucket_name}/default/hive/dt=${dt}'
)

SQL を書いて分析する

プレフィックスごとに、ストレージ使用量、月ごとのコスト、オブジェクト数を集計するクエリです。 transform 関数で key からプレフィックスの配列を生成している箇所がポイントです。

WITH splitted AS (
  SELECT
    -- prefix の配列を得る (計算量削減のため深さ 5 までに絞る)
    -- e.g. bucket_name='bucket', key='key/to/some/objects.gz'
    -- -> ['bucket/key', 'bucket/key/to', 'bucket/key/to/some', 'bucket/key/to/some/objects.gz']
    transform(
      sequence(
        1,
        array_min(
          array [cardinality(split(url_decode(key), '/')), 5]
        )
      ),
      x -> bucket_name || '/' || array_join(slice(split(url_decode(KEY), '/'), 1, x), '/')
    ) AS items,
    storage_class,
    size
  FROM
    workspace.s3inventory
  WHERE
    bucket_name IN ('output-bucket')
    AND dt = date_format(
      CURRENT_TIMESTAMP - INTERVAL '1' DAY,
      '%Y-%m-%d-01-00'
    )
),
grouped AS (
  SELECT
    prefix,
    storage_class,
    round(sum(size) / 1000000000.0, 1) AS size_GB,
    sum(
      CASE
        WHEN size > 0 THEN 1
        ELSE 0
      END
    ) AS object_count -- key にはオブジェクトの実態のないパスのみのレコードも含まれる
  FROM
    splitted,
    unnest(items) AS t(prefix) -- prefix ごとにレコードを複製する
  GROUP BY 1, 2
)
SELECT
  prefix,
  storage_class,
  size_GB,
  round(
    size_GB * CASE
      storage_class
      WHEN 'STANDARD' THEN 0.023 -- 最初の500TB までの価格は異なるが簡単のため定数として扱う
      WHEN 'GLACIER' THEN 0.0045
      WHEN 'DEEP_ARCHIVE' THEN 0.002
    END,
    1
  ) AS cost_per_month_in_dollars -- ref: https://aws.amazon.com/jp/s3/pricing/
,
  object_count
FROM
  grouped
ORDER BY 4 DESC, 1 ASC
LIMIT 1000

イメージですが、クエリを実行すると下記のような結果を得られます。どのプレフィックスでコストがかかっているのかを把握することができました。

プレフィックス別の S3 のコストの例

まとめ

今回の記事では、S3 のストレージ使用量を把握するための選択肢と、S3 Inventory の設定例、分析クエリを紹介しました。筆者のチームでは、使用量のボトルネックが可視化されたことでコスト削減金額の見積もりを立てやすくなりました。また意外なところでコストがかかっていたことが発見されて実際にコスト削減にも繋がりました。

この記事が読者様のお役に立てますと幸いです。

*1:厳密には出力ファイルを保持するためのストレージコストが別途かかります

*2:CSV も試したのですがクエリの実行速度に結構な差が出たので Parquet にしました

*3:Terraform ではなくマネジメントコンソールから S3 Inventory を設定する場合は自動で付与されます