🤖

タグを利用してS3のストレージ料金を7割削減した話

2023/01/09に公開

はじめに

先日S3のコスト削減プロジェクトを担当しました。
始めた当初は「バケットにライフサイクル設定すればいいんでしょ?」ぐらいの気持ちだったのですが、意外と考慮しなければならないことが多かったので紹介します。

コスト削減は以下のフローで進めたので、流れに沿って説明していきます。

  1. コスト削減対象の調査
  2. オブジェクトへのタグ付け処理の実装
  3. 移行先ストレージクラスの選定
  4. バケットへのライフサイクル設定

課題と実装方針

コスト管理するにあたってS3が現状どういった利用状況なのか調査したところ、以下の課題がありました。

  • S3に保存してあるオブジェクトは全て保存料金の最も高いS3スタンダードのストレージクラスを利用していた
  • 同一バケット内に利用頻度の異なるオブジェクトが混在していた

コスト管理を実施するまで、S3に保存してあるオブジェクトは全て保存料金の最も高いS3スタンダードのストレージクラスになっていました。

ベストプラクティスとしてはオブジェクトの利用頻度が低い場合は保存料金が安いストレージクラスに移行するようにライフサイクル設定を行います。
ライフサイクルはバケット単位で設定できるもので、一定の条件を満たしたオブジェクトを指定したストレージクラスに自動的に移行してくれます。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/object-lifecycle-mgmt.html

ここで問題なのが、今回のように同一バケット内に利用頻度の異なるオブジェクトが混在している場合、それらを見分けられるようにして、別々のライフサイクルを設定しなければなりません。

そういった場合にライフサイクルでは様々なフィルタを設定することができるのですが、今回はその中でも一番詳細にオブジェクトを管理できるタグのフィルタを利用しました。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/lifecycle-configuration-examples.html#lifecycle-config-ex1

タグによるフィルタではタグのkeyとvalueをライフサイクルで指定して、そのタグが付与されているオブジェクトに対してストレージクラスの移行を行います。

それではタグをオブジェクトに付与していきましょう。

オブジェクトへのタグ付け

タグ付けはSDKを利用することで実装できます。
PythonやJavascriptのSDKもありますが今回はRubyのSDKをベースに説明していきます。
https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#put_object_tagging-instance_method

# The following example adds tags to an existing object.

resp = client.put_object_tagging({
  bucket: "examplebucket", 
  key: "HappyFace.jpg", 
  tagging: {
    tag_set: [
      {
        key: "Key3", 
        value: "Value3", 
      }, 
      {
        key: "Key4", 
        value: "Value4", 
      }, 
    ], 
  }, 
})

新規でアップロードするオブジェクトへのタグ付け

これからアップロードするオブジェクトに対しては簡単にタグ付けが可能です。
アップロード処理の後にタグ付け処理のkey:valueを適宜変えて実装することで、同一バケット内の利用頻度の異なるオブジェクトにそれぞれ異なるタグを付与できます。

既にアップロード済みのオブジェクトへのタグ付け

既にアップロード済みのオブジェクトに対してのタグ付けは少し工夫が必要だったので、詳細に説明します。

Railsであればrakeタスクを利用してタグ付けを行います。
実装概要は以下の通りです。

1. バケット内のオブジェクトをリストとして一括取得する
2. リストをループ処理してその中でタグ付け処理を実行

まずオブジェクトをリストとして取得するのですが、2つ方法があります。

1つ目はS3:Resourceのobjectsを利用する方法です。こちらはバケット内のオブジェクトを一括で取得できます。
https://qiita.com/Kta-M/items/b9f22277e983a05389fb#オブジェクト一覧-1

2つ目はS3:Clientのlist_objects_v2を利用する方法です。こちらは一回の処理で1000件までしかオブジェクトを取得できないのですが、"continuation_token"という引数をメソッドに渡すことで次の1000件を取得することが可能になります。なのでこちらも結果的にバケット内のオブジェクトを全て取得できます。
https://qiita.com/Kta-M/items/b9f22277e983a05389fb#オブジェクト一覧

今回はlist_objects_v2を採用しました。
理由はオブジェクトの数が非常に多くrakeタスクが途中で終了するリスクがあり、rakeタスクを途中から再開したい可能性があったためです。

プロジェクトではKubernetesを利用しておりrakeタスクを実行する際は、別途podを立ち上げてそこで実行しています。このpodは突然終了したりすることもありますし、インターネット接続が途中で切れてしまいrakeタスクが中断するリスクがありました。
list_objects_v2を利用すれば、もし途中でrakeタスクの処理が止まってしまっても"continuation_token"を引数に渡せば途中から再開できるようにrakeタスクを実装することができます。オブジェクト数が少ないのであればobjectsを利用しても問題ないです。

また何回かrakeタスクを試してみると稀にタグ付け処理が失敗することがありました。
原因はオブジェクトの名称が適切でないためで、iOS端末から入力される特定の特殊文字がファイル名にあると発生するエラーでした。
こういったケースはタグ付け処理をしなくても良いと判断し、例外処理をrakeタスクに加えました。

以上を踏まえて作成したrakeタスクがこちらです。

# frozen_string_literal: true

require 'benchmark'

namespace :db do
  desc "Tag demo"
  task :tag_demo, ['continuation_token'] => :environment do |task, args|
    result = Benchmark.realtime do
      bucket_name = Settings.test
      options = { bucket: bucket_name }
      options[:continuation_token] = args.continuation_token ? args.continuation_token : nil
      begin
        loop do
          # list_objects_v2は最大1000件までしか取得できない
          # その代わりnext_continuation_tokenを発行していて、それをoptionに渡せば次の1000件を取得できる
          object_list = S3_CLIENT.list_objects_v2(options)
          current_continuation_token = object_list&.continuation_token
          object_list.contents.each do |object|
            key = object.key
                        # オブジェクト名の末尾4文字が"hoge"の時だけタグ付けする
            if key.slice(-4, 4) == 'hoge'
              # もしrakeが途中で止まったらこのトークンを引数に渡してrake再度実行
                          # bundle exec rake db:tag_demo["continuation_token"]
              puts "continuation_token: #{current_continuation_token}"
              S3_CLIENT.put_object_tagging({
                bucket: bucket_name,
                key: key,
                tagging: {
                  tag_set: [
                    {
                      key: "LifeCycleRule",
                      value: "GlacierInstantRetrieval1week",
                    },
                  ],
                },
              })
            end
          end
          options[:continuation_token] = object_list&.next_continuation_token
          break unless object_list.next_continuation_token
        end
      rescue
        next
      end
    end
    puts "Execution time: #{result}s"
  end
end

ストレージクラスの選定

S3のストレージクラスは様々な種類がありますが、主に以下の3つが異なっています。
1. 保存料金
2. 取り出し料金
3. 取り出し速度
これらの観点をもとに、オブジェクトに求める要件を定義し、ストレージクラスを使い分けることで、適切なコスト管理が可能となります。(ストレージクラスの詳細な違いは以下を参考)
https://aws.amazon.com/jp/s3/pricing/

今回のプロジェクトで扱うオブジェクトはどのストレージクラスが適しているのか検討するため、オブジェクトの使われ方を調査したところ、ほとんどは以下のどちらかに分類されました。

1. アップロードされてから数時間は利用されるが、一週間後には全く利用されることはない(削除はしたくない)
2. アップロードされてから数日は高頻度で利用されるが、1ヶ月経過後は低頻度で利用される(全く利用されない訳ではない)

1のケースではGlacier Deep Archiveが移行先のストレージクラスとして適しています。
Glacier Deep Archiveは最も保存料金が安価ですが、取り出すのに数時間から数日かかるためオブジェクトを利用しないが保存はしておきたい場合に最適です。
https://aws.amazon.com/jp/blogs/news/new-amazon-s3-storage-class-glacier-deep-archive/

2のケースではS3 Glacier Instant Retrievalが移行先のストレージクラスとして適しています。
S3 Glacier Instant RetrievalはS3スタンダードと比べて保存料金が低いですが、取り出し速度が変わらないのが魅力です。取り出し料金がかかることは注意点ですが、今回のようにオブジェクトの利用頻度は低いが取り出し速度は遅くしたくはない場合におすすめです。
特にこちらは利用頻度はほとんどないけどユーザーに提供する可能性があるオブジェクトに適用できるので、覚えておくと便利です。
https://aws.amazon.com/jp/about-aws/whats-new/2021/11/amazon-s3-glacier-instant-retrieval-storage-class/

ライフサイクルの設定

あとはS3のライフサイクルにて以下の設定をするだけです。

  • フィルタで指定するオブジェクトに付与されたタグのkeyとvalue
  • アップロードから何日経過したらストレージクラスを移行するのか
  • どのストレージクラスに移行するのか

設定後は毎日9:00(JST)にジョブが実行され、条件を満たすオブジェクトが指定されたストレージクラスに移動します。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/intro-lifecycle-rules.html

まとめ

タグ付けを利用することで臨機応変なコスト削減が実現できました。
S3は簡単に使い始められますが、気づいたらコストが嵩んでいることが多いですよね。
AWS側はコスト削減の手段をちゃんと提供してくれているので、現状のオブジェクトの使われ方を見直してコスト削減してみましょう。

Discussion