S3のGETリクエストでRangeヘッダーを使う

S3のGETリクエストでRangeヘッダーを使う

久しぶりの投稿です。

先日とあるプロジェクトで、Amazon S3に保存されているCSVファイルをデータベースにインポートする機能をPHPで作りました。
PHPではCSVを fopen と言う関数を使う事で、ストリームからCSVの行を1行ずつ配列として受け取る事ができます。

さて、今回要件として、CSVファイルは最大数GBまで許容されると言う事と、ジョブは並列で動作するのでローカルディスクにこのデータ全体を一旦DLするのはNG [1] と言う条件がありました。
ローカルディスク上に保存されているファイルであれば fopen で開いたストリームを使って問題なく fgetcsv が処理できますが、このような事情の為、何か良い方法が無いか調べた所、S3のGETリクエストは  Range (RFC 2616) が使える事が分かりました。

これは、HTTPのリクエストヘッダーに Content-Range: bytes 0-100000 の様に指定すると、 先頭の100KBまでのチャンクをダウンロードできると言う物です。
これを使う事で、部分的にCSVのデータを取得してそれをパースしていけば、メモリもディスクスペースも使わずにインポートが実現できます。

因みに、Content-Rangeに指定する値のフォーマットは色々あるので詳しくは RFC 2616 を参照の事。

実際に使ってみる

今回はPHPで書いているので、aws-sdk-php を使います。
実はContent−Rangeの指定には特殊な処理は必要なく、単純に GetObject のoptions にRange として渡せばOKです。

$object = $s3->getObject(['Bucket' => 'test', 'Key' => 'awesome.csv', 'Range' => sprintf('bytes=%s-%s', 0, 1024 * 1024 * 10)]));

echo $object->__toString(); // CSVの最初の10MBを取得

後は、チャンクごとにCSVを読み込んでいく訳ですが、これにはちょっとした下処理が必要で、1つのチャンクで完全なCSV行が読み取れない場合があるので、その場合はそのチャンクをバッファリングしておいて次のチャンクと連結して再度パースする必要がある為です。

詳細は長くなるので割愛しますが、GitHubにサンプルコードを用意したので興味のある方は見てみて下さい。
そこでは最後の3行でCSV行をループで検証していますが、1GB超の巨大なCSVファイルであってもメモリは12MB前後しか消費されず、検証は成功と言えます。

foreach ($csvRows as $v) {
    echo \sprintf('%s, %s / %.4f MB', $v[0], $v[4], \memory_get_usage(true) / 1024 / 1024), "\n";
}

  1. AWS SDK for PHPのS3 GetObject のBodyはPSR-7のStreamで表現されていますが、実態は php://temp なので、ファイルサイズがでかいとディスク容量を圧迫します ↩︎