🤖

JavaのGZIPInputStreamのバグを踏んだ話

2024/06/17に公開

TL;DR

  • java.util.zip.GZIPInputStreamにはバグがあり、gzipのstreamの終端(end of stream, EOS)を正しく判定できない場合がある
    • InputStream.available()が0以上であることをEOSの判定条件の1つに使っているが、InputStreamの実装によっては0が必ずしもEOSを表すとは限らない
    • 特に、concatenatedなgzip(複数のgzipを連結したgzip)だと処理が途中で終わってしまう可能性がある
  • 10年以上前からバグとして報告されていたが、2024年になってようやく修正され、JDK 23でリリース予定

気がついた経緯

AWS S3にアップロードされたgzipファイル(中身はJSON Lines)をダウンロードして処理するようなJavaで書かれたバッチがあり、Javaのバージョンアップついでにライブラリもアップデートしようという話になりました。
その際、これまで使っていたAWS SDK for Javaのv1をv2(v1とv2は全く別物です)に変更することになりました。S3からのstreamを受け取ってgzipファイルの解凍などの処理をするメソッド自体は下記のように元からInputStreamを受け取るようになっていたため、S3からstreamを受け取る処理周りのみを変更しました。

public List<Hoge> readFile(InputStream inputStream) {
  try (BufferedReader bufferedReader =
      new BufferedReader(
          new InputStreamReader(new GZIPInputStream(inputStream), StandardCharsets.UTF_8))) {
    // 処理
  } catch (IOException e) {
    throw new RuntimeException(e);
  }
}

変更後に自動テストやステージング環境でのテストは実施しましたが、リリース後に、バッチが正常終了しているにも関わらずjsonlファイルの途中の行までしか読み込めていないケースがあるということが発覚しました。

軽く調査してみたところ、

  • ローカルでも再現できる
  • サイズの大きいもののみこの現象が発生する
  • 実行の度に読み込める行数が変わるが、行数は一様ランダムな値というわけではなく、特定の値のみ取りうる模様
  • AWS SDKをv1に戻すと全行読み込める(つまりJavaのバージョンアップが原因ではなさそう?)
  • ローカルにファイルを置いてFileInputStreamを渡しても全行読み込める
  • AWS SDKのGitHubリポジトリにそれらしきissueなし

という状況でした。
かなり時間がかかってしまいましたが、試行錯誤をしているうちにconcatenatedなgzip(複数のgzipを連結したgzip)の場合にのみバグが発生するということに気がつき、もしかするとAWS SDKではなくGZIPInputStreamの方がおかしいのではと考え、下記のstack overflowの回答に辿り着きました。
https://stackoverflow.com/a/41476316

原因はGZIPInputStreamにあり

前提知識として、gzipは下記のように単純にconcatenate(連結)できるような仕様となっています。

$ cat file1.gz file2.gz file3.gz > allfiles.gz

java.util.zip.GZIPInputStreamはこのようなconcatenatedなgzipに対応するため、以下のようなメソッドを利用しています。
https://github.com/openjdk/jdk/blob/890adb6410dab4606a4f26a942aed02fb2f55387/src/java.base/share/classes/java/util/zip/GZIPInputStream.java#L217-L255
このコードで問題となるのが、this.in.available() > 0の部分です。availeble()の返り値が0より大きい場合はinから追加で読み取り可能と判定し、実際にreadHeader(in)することで連結された次のgzipのヘッダを読み取っています。
しかし、抽象クラスであるjava.io.InputStreamを見てみるとavailable()メソッドの説明としては下記のように書かれてます。
https://github.com/openjdk/jdk/blob/890adb6410dab4606a4f26a942aed02fb2f55387/src/java.base/share/classes/java/io/InputStream.java#L646-L653
ここにある通り、available()メソッドの返り値はblockingせずにstreamから読み込めるバイト数の推定値、もしくは、もうこれ以上読み込めない場合は0、となります。つまり、0が返ってきたからといってこれ以上streamからは読み込めない(end of stream, EOS)とは限りません。極端な話、available()が常に0を返すのもありということです。

もしも、S3のstreamがavailable()メソッドで0を返し、さらにコード中のn(入力バッファに残っているバイトの総数のようです)が26以下であった場合、そこでEOS判定されてしまい、処理が終了してしまいます。それでgzipファイルの途中までしか読み込めないという事象が発生したというわけです。今回AWS SDK for Javaをv1からv2に切り替えた際に、S3のstreamクラスのavailable()メソッドの仕様が変わった結果、バグが顕在化したようです。

このバグは2011年に報告されていましたが、10年以上も修正されずにいました。
https://bugs.openjdk.org/browse/JDK-7036144
しかし、2023年末にPull Requestが出され、2024年4月に無事に修正が完了しました。10年以上修正されることがなかったにしてはコードの修正量がかなり少ないですね。
https://github.com/openjdk/jdk/pull/17113
この修正は少なくともOpenJDK 23には反映されるようですが、上記PRのやり取りを見た限りでは、17や21へは反映される気配はなさそうです(もし反映されていたらコメント等でお知らせいただけると幸いです)。

対処方法

OpenJDK 23は2024年9月にGAされる予定のようですが、JDKのバージョンアップ以外の対処方法を紹介します。
私のチームでは上記のstack overflowでも紹介されていたApache CommonsのGzipCompressorInputStreamを使うことにしました。
https://commons.apache.org/proper/commons-compress/apidocs/org/apache/commons/compress/compressors/gzip/GzipCompressorInputStream.html
使い方は簡単で、new GZIPInputStream(stream)new GzipCompressorInputStream(stream, true)のように置き換えるだけです。
また、下記の記事にもあるように、available()が0を返さないようにstreamをラップした自作クラスを利用するという方法もありです。
https://pzampino.github.io/2018/10/09/gzipinputstream-and-available.html

個人的な感想

GZIPInputStreamはかなり前からあるはずで私自身も長い間利用していただけに、ユーザーが遭遇しやすそうで割と単純そうに見える部分にバグが残り続けていたのは意外でした。
初めはAWS SDK for Java v2のバグを疑っており、適当に検索をかけたり、GitHubのissueを眺めたりしてもそれらしきものが見つからず、SDKのコードを読んだりSDK側にbreak pointを置いてデバッグしたりしていました。そのため、GZIPInputStreamのバグにたどり着くまでにかなりの時間がかかってしまいました。この手のバグ調査やGoogle検索はかなり得意だと思っていただけに悔しいです。
そもそもInputStreamavailable()がこの定義だとかなり使いづらそうで、変更しなくていいのかな?と思ったりしました(変更するとなると影響が大きくかなり大変そうですが)。

参考記事・サイトなど

Discussion