esm アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

たくさんのファイルを巨大なアーカイブファイルにしてアップロードする

たくさんのファイルを同時に扱う際には、その処理が使うリソースに注意したいものです。さいきん私が直面した課題は、AWS S3 のあるバケットに保存されている大量のファイルをひとつのアーカイブファイルにして別のバケットに配置するというものでした。ひとつのアーカイブに含まれるファイルの数は最大で70万くらいあり、各ファイルのサイズは 10MiB を上限にさまざまです。ちなみに、アーカイブ対象のファイルをすべて合わせると、140GiB 以下であることがわかっています。

もしも70万のファイルを一気にダウンロードしてZIPアーカイブしようとしたら、ある程度のディスク容量が確保できていることを事前に確認しておいたほうがよさそうです。どれくらい必要でしょうか。もしアーカイブ処理を同じマシン上で平行して実行すると、必要な容量はさらに掛け算で増えていきそうです。そういえば、できあがったアーカイブファイルは数十GiBのサイズになる可能性がありそうですが、そもそも、果たしてそんなに大きなファイルは一度にアップロードできるんでしょうか。心配事がいろいろあります。こういった場面では、一度にすべて片付ける代わりに、少しずつ処理していくパイプラインっぽいやり方で対応できるとよさそうです。

AWS S3 Multipart Upload で少しずつアップロードする

幸い、S3 は巨大なファイルを小さなパーツに分割して少しずつアップロードする "multipart upload" と呼ばれる手段を用意しています。"multiple upload" は、アップロード対象のファイルが完成していなくても開始できるようになっています。つまり、どこかにアップロードするために、アーカイブファイルをいったん手元のファイルシステムに保存する必要は必ずしもないということです。

RubyAWS SDK も、もちろんこの機能を使う API を備えています。以下はその一例です。

s3_client = Aws::S3::Client.new(region: 'ap-northeast-1')
archive_object = Aws::S3::Object.new(bucket, 'archive.zip', client: s3_client)
archive_object.upload_stream do |upload_stream|
  upload_stream.binmode

  # write to upload_stream (one side of a pipe, IO object for writing)
end

Aws::S3::Object#upload_stream で miltiple upload の開始を知らせると、AWS SDK はブロック引数を介して IO オブジェクトを返してきます。これは IO.pipe で作成されたパイプの書き込み側なのですが、とにかく、ここにどんどんファイルの中身を書き込んでいけばライブラリが勝手にファイルを適当なサイズ (デフォルトは 5MiB) で分割しつつ、少しずつS3バケットにアップロードしてくれます。べんり!

ディスクに書き込まずに ZIP ファイルを作る

ファイルをアーカイブするとき、できあがったアーカイブファイルをまるごと保存するためのディスク容量を用意する必要がないといいですね。実は、ZIPはそういう要求にうってつけのフォーマットになっています。

ZIP フォーマットの構造

ほかのアーカイブフォーマットと同じようにZIPファイルも、アーカイブしているファイルの名前やファイルサイズ、データ位置といったメタデータを保持している場所があります。"central directory header" と呼ばれる領域で、興味深いことに、"header" という名前に反して ファイルの末尾に配置されることになっています

つまり、ZIPファイルを作成し始める段階では、すべてのアーカイブ対象のファイルの情報を手元に持っていなくても問題ありません。それらの情報は最後に central directory を書き込むときに用意できていれば十分です。それまでは、ファイル名などのメタデータをとっておきつつ、単純に圧縮したファイルをひとつずつ頭から書き込んでいけば大丈夫です。ファイルの中身を圧縮してアーカイブに追加できた時点で、元のファイルは不要になります。ですので、ZIP形式のアーカイブを作る際に、対象のすべてのファイルをあらかじめ手元のファイルシステムに保存しておく必要はありません。

パイプと zip_tricks gem

今回は ZIP アーカイブを作成するために zip_tricks gem を選択しました。理由は、このライブラリではZIPファイルの出力先としてパイプを使えるためです。ZipTricks::Streamer.open はZIPファイルの出力先としてIOオブジェクトを受け取るのですが、このライブラリは受け取ったIOオブジェクトを rewind しないため、パイプを渡すことができます。

for_write, for_read = IO.pipe # for_read can be used to upload

ZipTricks::Streamer.open for_write do |zip|
  zip.write_deflated_file 'a-file.txt' do |input_stream|
    input_stream.write 'the content of a-file.txt'
  end
end

出力先にパイプが使えるということは、できあがったZIPアーカイブファイルを手元のファイルシステムに保存しないという選択肢がとれるようになります。そういえば、S3 の multipart upload のセクションで、Aws::S3::Object#upload_stream はブロック引数としてパイプのIOオブジェクトを提供していましたね。つまり zip_tricks を使うと、次のように、アーカイブの出力をそのパイプに直接書き込めるようになります。

archive_object.upload_stream do |upload_stream|
  upload_stream.binmode

  # upload_stream is a pipe
  ZipTricks::Streamer.open upload_stream do |zip|
    zip.write_deflated_file 'a-file.txt' do |input_stream|
      input_stream.write 'the content of a-file.txt'
    end
  end
end

サンプルコード全体

これで、手元でファイルを作成せずに、たくさんのファイルをアーカイブし巨大なアーカイブをアップロードできるようになりました。

require 'aws-sdk-s3'
require 'zip_tricks'

bucket = 'hibariya-sandbox'
s3_client = Aws::S3::Client.new(region: 'ap-northeast-1')

files_to_archive = %w[alpha bravo charlie delta] # whatever
archive_object = Aws::S3::Object.new(bucket, 'archive.zip', client: s3_client)

archive_object.upload_stream tempfile: false, part_size: 20 * 1024 * 1024, thread_count: 3 do |upload_stream|
  upload_stream.binmode

  ZipTricks::Streamer.open upload_stream do |zip|
    files_to_archive.each do |file_path|
      zip.write_deflated_file file_path do |input_stream|
        s3_client.get_object bucket: bucket, key: file_path, response_target: input_stream
      end
    end
  end
end

Aws::S3::Object#upload_stream に渡しているキーワード引数はすべて任意です。詳しくは 公式のドキュメント を確認してください。 ※話を簡単にするために、アップロード/ダウンロード両方に同じバケットを使用しています。

注意点など

AWS S3 の multiple upload を使う場合は、分割できるパーツの数や未完了のアップロードへの課金など、いくつか知っておいたほうが良い事項があります。おそらく、未完了のアップロードを定期的に掃除するためにS3バケットにライフサイクルポリシーを追加したくなるのではないかと思います。詳細は公式の記事をご確認ください。

https://docs.aws.amazon.com/AmazonS3/latest/dev/mpuoverview.html