[S3, CloudFront] アップロード済みファイルのメタ情報を一括で変更する

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

既に S3 アップロードされているファイルの メタ情報 を変更する場合、通常は S3 の Management Console にある Properties で設定でき、設定可能なメタ情報には、 Content-Type, Content-Encoding, Content-Language, Cache-Control, Expires 等があります。

今回は、既に S3 にアップロード済の大量のファイルに対し、一括でメタ情報の変更を行い、 CloudFront のキャッシュを削除するまでの手順をまとめます。

実行環境

以下は ruby 2.1.2p95, Ruby AWS SDK ver2 を使用する前提で説明しています。

手順

今回は my-bucket という名前のバケットの、 mydir/images/... 以下にある、 500件程度のjpegファイルCache-Control 情報を変更してみます。

前提

ruby AWS SDK ver2 を使用します。
以降のコードで出てくる s3 は、全て次のように初期化されたものです。

require "aws-sdk"

s3 = Aws::S3::Client.new(
  region: REGION_NAME,
  credentials: CREDENTIALS #省略可能
)

処理対象ファイルのリストを取得する

今回、処理対象となるファイルは mydir/images/ 以下の jpegファイル です。
対象ファイルの絶対パスのリストは、次のように取得できます。

paths = s3.list_objects(
  bucket: "my-bucket",
  prefix: "mydir/images/"
).contents
  .map { |c| c.key }
  .select { |path| path.end_with? ".jpg" }

まず list_objects メソッドで、 my-bucket にある "mydir/images/" 以下のファイル ( 厳密には、キー名が "mydir/images/" から始まるファイル ) のリストを取得します。

リザルトの contents リストに含まれるオブジェクトの key が、対象ファイルまでの絶対パスを表しています。

list_objects メソッドでは、対象とする拡張子を指定することができません。そのため、最後に .jpg で終わるものを抽出しています。

この結果、今回は次のようなパスのリストが取得できます

[
  "mydir/images/001.jpg",
  "mydir/images/002.jpg",
  "mydir/images/003.jpg",
  ...
]

このパスに対して、メタ情報の変更を行います。

S3オブジェクトのメタ情報を更新する

AWS SDK には、S3オブジェクトのメタ情報を更新するための機能は用意されていません。メタ情報を付加することができるタイミングは、 新たにオブジェクトをPUTする際か、存在するオブジェクトをコピーして新しいオブジェクトを作成する時 です。
そのため、次のような手順を踏みます。

  1. 対象のオブジェクト(A) を別名のオブジェクト(B) としてコピー
  2. 対象のオブジェクト(A) を消去
  3. 別名のオブジェクト(B) を元の名前(A) として再度コピー ← ここでメタ情報を更新
  4. 別名のオブジェクト(B) を削除

今回は、 Cache-Control の情報を更新します。

paths.each do |path|
  p "processing #{path}"

  # .jpg.tmp としてコピー
  s3.copy_object(
    bucket: "my-bucket",
    copy_source: "my-bucket/#{path}",
    key: "#{path}.tmp"
  )

  # .jpg の方を消去
  s3.delete_object(
    bucket: "my-bucket",
    key: path
  )

  # .jpg.tmp を .jpg としてコピー
  # ここでメタ情報を設定
  s3.copy_object(
    acl: "public-read",
    bucket: "my-bucket",
    copy_source: "my-bucket/#{path}.tmp",
    key: key,
    cache_control: "max-age=86400",
    content_type: "image/jpeg",      # --- (1)
    metadata_directive: "REPLACE"    # --- (2)
  )

  # .jpg.tmp を削除
  s3.delete_object(
    bucket: "my-bucket",
    key: "#{path}.tmp"
  )
end

ここで、注意すべき点は (1), (2) の箇所です。

まず、コピー時にメタ情報を上書きするためには (2) のように metadata_directive"REPLACE" を指定しなければなりません。このプロパティは、デフォルトで "COPY" という設定値になっており、そのままだと コピー元オブジェクトのメタデータを優先 します。

そして、 metadata_directive"REPLACE" を指定した際に注意すべき点が (1) です。この設定値を指定した場合、 コピー元に存在するメタデータは、明示的に指定する必要があります。 指定しない場合は ""(空白文字) が設定されてしまいます。

今回、もともとのオブジェクトには Content-Type: image/jpeg が設定されていました。( jpegファイルなので当然ですが... ) これを明示的に再指定してやらないと、 Content-Type:   になってしまいます。

なので (1) の箇所でこれを明示的に指定しています。他にも既に設定されているメタデータがある場合には、同様に処理する必要があります。

CloudFront のキャッシュ消去 (Invalidation) を行う

CloudFront を使用していない場合は必要ありません。

もし、 "my-bucket" をオリジンに設定している CloudFront があり、そのキャッシュ有効期限が長いのであれば、S3オブジェクトのメタデータを更新した後に CloudFront のキャッシュを消去する必要があります。

次の手順では、CloudFront の

require "securerandom"

# ... 中略 ...

cloudfront = Aws::CloudFront::Client.new(
  region: REGION_NAME,
  credentials: CREDENTIALS # 省略可能
)

cloudfront.create_invalidation(
  distribution_id: DISTRIBUTION_ID,
  invalidation_batch: {
    paths: {
      quantity: paths.size,
      items: paths.map { |path| "/" + path }
    },
    caller_reference: SecureRandom.uuid
  }
)

:distribution_id には、 CloudFront の Distribution の ID を指定します。 これは、Management Console で ID として表示されています。

:paths には、キャッシュを削除するパスのリストと、渡すパスの個数を指定します。また、Aws::CloudFront::Client でパス指定をする際には "/mydir/myimage.jpg" のように、パスの最初にルートを示す "/" が必要 なので注意してください。

:caller_reference には、各リクエスト毎に一意な文字列を指定します。この文字列が同じリクエストが複数回送信されても、 CloudFront は最初の1つしか受け入れません。多重リクエストを防ぐ仕組みのようです。

まとめ

全体として、次のようなスクリプトになりました。

require "aws-sdk"
require "securerandom"

REGION_NAME = "us-east-1" # your region name
BUCKET_NAME = "my-bucket" # your bucket name
CREDENTIALS = ... # your credentials

CACHE_CONTROL = "max-age=86400"
CONTENT_TYPE = "image/jpeg";

s3 = Aws::S3::Client.new(
  region: REGION_NAME,
  credentials: CREDENTIALS
)

paths = s3.list_objects(
  bucket: BUCKET_NAME,
  prefix: "mydir/images/"
).contents
  .map { |c| c.key }
  .select { |path| path.end_with? ".jpg" }

paths.each do |path|
  s3.copy_object(
    bucket: BUCKET_NAME,
    copy_source: "#{BUCKET_NAME}/#{path}",
    key: "#{path}.tmp",
  )

  s3.delete_object(
    bucket: BUCKET_NAME,
    key: path
  )

  s3.copy_object(
    acl: "public-read",
    bucket: BUCKET_NAME,
    copy_source: "#{BUCKET_NAME}/#{path}.tmp",
    key: path,
    cache_control: CACHE_CONTROL,
    content_type: CONTENT_TYPE,
    metadata_directive: "REPLACE"
  )

  s3.delete_object(
    bucket: BUCKET_NAME,
    key: "#{path}.tmp"
  )
end

## 以下は CloudFront 利用している場合

DISTRIBUTION_ID = "..." # your id

cloudfront = Aws::CloudFront::Client.new(
  region: REGION_NAME,
  credentials: CREDENTIALS
)

cloudfront.create_invalidation(
  distribution_id: DISTRIBUTION_ID,
  invalidation_batch: {
    paths: {
      quantity: paths.size,
      items: paths.map { |path| "/" + path }
    },
    caller_reference: SecureRandom.uuid
  }
)