S3にある大きなファイルをNode.jsのLambdaでZIPにしようとして失敗した話

SDKを使えばLambdaのストレージを気にせずファイルをS3からS3へ移動できるので、LambdaでZIPアーカイブ処理もできそうな感じがします。ところが実際にやってみたら大失敗したので、その内容を共有します。
2021.01.29

本稿は、AWS SDK for JavaScript v2の使用において確認されている内容です。

経緯

あるとき、S3に置いてあるファイルをLambdaでZIPアーカイブ化できないかという話があがりました。しかし、扱うファイルのサイズが大きく、Lambdaのストレージにはファイルを一時的に保存できません。こうなるとEFSを使うか、あるいはLambda以外でやるしかないか、と悩んでいました。調べていく中で、ちょうどこのケースに応用できそうな記事を見つけました。その内容を参考に、Node.jsのStream APIを使った方法を試してみることにしました。

今回ZIPアーカイブ処理に使用したのはArchiverというライブラリです。Stream APIを扱えるZIPアーカイブ用途のライブラリということで選定しました。

この方法は、途中までは非常にうまく行きました。S3からファイルを取得するのも、アーカイブしたファイルをS3に配置するのも、一連の処理として問題なく完遂できるようでした。ところが、ファイルの合計サイズを大きくしていったときに、途中で処理に失敗するようになりました。

どうして失敗するのか

Archiverでは、アーカイブ対象のファイルを追加すると非同期にファイル読み込みが始まりますが、この状態でS3 GetObjectのcreateReadStreamメソッドを使って複数のファイル読み込みストリームをあらかじめ生成してしまうと、あとから追加したファイルのストリームがタイムアウトしてしまうことがわかりました。

大きめのテスト用ファイルを複数用意し、それらの読み込みストリームを全てあらかじめ生成した上で順にArchiverへ追加し、処理の様子をログに書き出して追ってみると、ファイルは1つずつ直列に読み込まれて処理されていることがわかります。そして、先行するファイルの読み込みが行われる間、後続のファイルのストリームが、接続を開始したあと何も起こらないまま待たされてしまっているようです。AWS SDK for JavaScriptでは、設定を特に与えていない場合、デフォルトの120000msで接続はタイムアウトします。接続が失われますから、ファイルの取得には失敗します。

さらなる問題とその解決

それでは、タイムアウトまでの時間を長めに設定すれば、この問題は解決するのでしょうか? AWS SDK for JavaScriptでは、タイムアウトまでの時間をミリ秒単位で設定できます。Lambdaで処理することをふまえ、以下のようにタイムアウトを900000msに設定してみます。

const s3 = new aws.S3({
  httpOptions: {
    timeout: 900000,
  },
})

これを超過してしまうということは、そもそもLambdaで処理しきれないファイルサイズだということです。しかし、実際にこの設定を行うと、以下のようなログが得られます。

Error: read ECONNRESET
    at TLSWrap.onStreamRead (internal/stream_base_commons.js:208:20)
Duration: 340001.25 ms	Billed Duration: 340002 ms	Memory Size: 3008 MB	Max Memory Used: 181 MB	Init Duration: 465.34 ms

900秒よりもずっと早くに、処理は中断されてしまいました。接続がリセットされているようです。1GBのファイルを10個処理させてみると、ファイルを6つ目までは処理できますが、7つ目のファイルを読み込もうとするタイミングで失敗します。ストリームを6つ処理している間に、7つ目以降のストリームで使われている接続が、放置された末に切断されているようです。

以上のことから、今回の方法でのZIPアーカイブ処理を実現しようと思うと、S3 GetObjectのcreateReadStreamメソッドでストリームのオブジェクトを生成したあと、時間を空けずに読み込みを行うよう工夫する必要があると考えられます。実際に動くコードの例をGistに置きました。前の読み込みストリームの終了を待ってから次のファイルをappendするような形にしています。

おしまい

今回は、S3上の大きなファイルをS3 GetObjectのcreateReadStreamメソッドで取得する場合の失敗例をご紹介しました。

ライブラリを使用したZIPアーカイブ処理という特殊な例でしたが、Stream APIによる処理と相性の悪いケースは、ほかにもあるかもしれません。また、こういった類の処理では、一見コードを書くことで解決できそうでもパフォーマンス面で無理が出てくるものがあり、コードを書かない方法を模索したほうがいい場面もあるでしょう。

ご紹介したケースだと、数GB程度までなら処理が正常に完了できてしまい、一瞬問題を解決できたように見えてしまうのがちょっとややこしいところです。実際に合計ファイルサイズを極端に大きくしてみるまで、この挙動には全く気がつきませんでした。もし気づかないままこの方法を使っていたら、扱うファイルサイズが運用する中で徐々に大きくなっていったときに、いきなり処理が失敗するという事態になっていただろうと思います。