Lambdaで巨大なファイルを処理する方法

こんにちは。ATOM 事業部エンジニアの田村です。
広告媒体からのデータ取得処理や、レポート生成処理の開発・保守をしています。

今回は Lambda を使って巨大なファイルを処理する方法を紹介します。
また、S3へのアップロードをストリームで行う方法について、検索してもそれらしい日本語の情報が少ないので、ついでにここでやり方を紹介します。

ストレージをどうするか

Lambda での巨大ファイル処理の際に問題となるのがストレージです。
512 MB しかないため、それ以上のファイルサイズを扱うことができません。

AWS Lambda 実行環境 - AWS Lambda

各実行環境は、/tmp ディレクトリ内の 512 MB のディスク領域を提供します。ディレクトリのコンテンツは、実行環境が停止された際に維持され、複数の呼び出しに使用できる一時的なキャッシュを提供します。

EFS

新機能 – Lambda関数の共有ファイルシステム – Amazon Elastic File System for AWS Lambda | Amazon Web Services ブログ

これが正攻法だと思います。 しかし扱うデータ量によっては、それなりにコストがかかります。

EFS を利用するためには Lambda 関数を VPC 内に配置する必要があるのですが、VPC に入れるとインターネットとの通信や、 S3 など他の AWS サービスの利用が制限されます。NAT や VPC Endpoint などを設定することで回避できますが、実験や開発中など気軽に作ったり壊したりにはちょっと面倒です。

メモリ

Lambda に割り当て可能なメモリの上限が 10 GB に拡張されました。
そうだ! すべてメモリ上にバイト配列として持ってしまえば(略

New for AWS Lambda – Functions with Up to 10 GB of Memory and 6 vCPUs | AWS News Blog

当然ながら料金も高く、10 GB だと最小である 128 MB の80倍です。 要件によっては選択肢に入ってくるかもしれませんが、さすがに 10 GB となると「金ならいくらでもある!」という人以外にはおすすめできません。

番外 EC2

そもそも Lambda ではなく、単純に EC2 インスタンスで処理を行うという方法もあります。

パフォーマンスやコストの面で不利なのは否めませんが、扱うデータ数・データ量があまり多くないのであればこれで十分です。 ストレージサイズも性能も必要に応じて増やせるので、Lambda を使うメリットがそこまで重要でないのであればこちらも選択肢に入るかもしれません。

ストレージに保存しない方法

問題を元から解決する方法として、ストレージにファイルを保存しない方法を考えてみます。

処理の内容によっては無理ですが、バイト列を先頭からストリーム処理できるものであれば、ストレージは必要なさそうです。
またストレージ I/O が発生しないため、パフォーマンスが良くなるというメリットもあります。

パフォーマンスは Lambda の実行時間 = コストにも響いてくるので、基本的に Java で書いていきます。 バッチ的にまとめて処理するのであれば Java のコールドスタートの遅さは無視できるので。

例 : hash 計算

例えば Java だと、Google が提供している guava には hash 計算しながら読み込む InputStream があります。

HashingInputStream (Guava: Google Core Libraries for Java HEAD-jre-SNAPSHOT API)

このようにファイルに保存せず hash 結果だけを取得することができます。

HashingInputStream hin = new HashingInputStream(Hashing.sha1(), in);
    while (hin.read() != -1) {
        // inputStream を空回しするだけ
    }
System.out.println(hin.hash());

python なら hashlib と StreamingBody あたりでできそうです。

hashlib --- セキュアハッシュおよびメッセージダイジェスト — Python 3.9.4 ドキュメント

Response Reference — botocore 1.21.52 documentation

例 : gzip 圧縮

S3 からファイルをダウンロードして gzip 圧縮し、それをまた S3 にアップロードする処理を考えてみます。
単純に考えると

  1. S3 から圧縮前ファイルをダウンロードしてストレージに保存
  2. 圧縮したファイルをストレージに保存
  3. ストレージにある圧縮したファイルをS3 にアップロード

このようになりそうですが、これでは圧縮前・圧縮後のファイルを保存する分のストレージを消費してしまいます。

例によって Java で考えてみます。

PipedInputStream, PipedOutputStream というものがあります。
PipedInputStream (Java SE 11 & JDK 11 )
PipedOutputStream (Java SE 11 & JDK 11 )

これは入力・出力ストリームを直接接続して、入力されたデータをファイルに保存せず直接メモリ内で受け渡して出力します。

また、出力時に gzip 圧縮する GZIPOutputStream があります。
GZIPOutputStream (Java SE 11 & JDK 11 )

これは入力されてきたデータを圧縮しながら出力に渡します。
これらを組み合わせれば、入力されたデータを圧縮しながら直接出力することができます。ストレージに保存する必要はありません。

逆に gzip 圧縮されたファイルを展開する場合は、読み込み時にGZIPInputStream を利用すれば可能です。
GZIPInputStream (Java SE 11 & JDK 11 )

S3 へストリームアップロード

ここで問題があります。
ファイルからでなくストリームで S3 にアップロードするためには、送信するデータのバイト数を Metadata で指定する必要があります。

ObjectMetadata meta = new ObjectMetadata();
meta.setContentLength(123456789); // 圧縮済みファイルのバイト数

s3Client.putObject(bucket, key, inputStream, meta); 

AmazonS3Client (AWS SDK for Java - 1.12.78)

しかし実際に圧縮してみないと、圧縮済みのバイト数がわかりません。

試しに setContentLength() を指定しないで実行すると、このような警告メッセージが出ます。

No content length specified for stream data.  Stream contents will be buffered in memory and could result in out of memory errors.

AWS SDK は出力をいったんメモリ上にバッファして、総バイト数をカウントしてから出力する実装になっているようです。確かにこれではメモリを食いつぶしてしまいます。

さて困りましたね・・・

(あまりエレガントでない)解決法

圧縮してみないとバイト数がわからないのなら、実際に圧縮してみるというのはどうでしょうか。 最初にダウンロード・圧縮するがファイルには保存せず、圧縮結果のバイト数をカウントするだけの処理を入れることにします。

出力したバイト数をカウントするだけでデータを保存しない ByteCountOutputStream を用意します。 これぐらいの単純な実装で十分です。

public class ByteCountOutputStream extends OutputStream {

    private long c = 0;
    
    @Override
    public void write(final byte[] b, final int off, final int len) {
        c += len;
    }

    @Override
    public void write(final int b) {
        c++;
    }

    @Override
    public void write(final byte[] b) {
        c += b.length;
    }

    public long getCount() {
        return c;
    }
}

これで先に圧縮後のバイト数をカウントしておきます。 その結果をもって本来のダウンロード・圧縮・アップロードを実行すれば、ストレージを一切使わないで gzip 圧縮が完了します。

コスト

このやり方だと S3 に GET リクエストを余分に1回投げることになりますが、GET リクエスト数の料金は非常に安く設定されているので、EFS を利用するよりは安く済みます。 Lambda の実行時間も増えるはずですが見積もりは難しいので、ある程度の数・データ量で実際にやってみて当たりをつけるといいと思います。 また、Lambda が出力するログも CloudWatch Logs の料金に乗ってきます。 大量に実行する際は気にしたほうがいいでしょう。
AWS Compute Optimizer を利用して調整するのもいいかもしれません。
AWS Compute Optimizer を使用した AWS Lambda のコストとパフォーマンスの最適化 | Amazon Web Services ブログ

おわりに

今回は Lambda で巨大なファイルを処理する方法についてお届けしました。
Lambda はいろいろなサービスから利用できるため、組み合わせによって利用の幅は広がると思います。 さらに上手いやり方や活用方法など、なんでもアイデアがあったら教えていただけると嬉しいです。

最後までお読みいただきありがとうございました。