No space left on deviceがGlueで出たので、圧縮に興味を持った話

この記事は、CYBOZU SUMMER BLOG FES '26の記事です。

こんにちは。kintoneのダッシュボード開発をしているuta8aです。好きなAWSのサービスはCloudwatch Metricsです。今日はAWS Teamとして、普段AWSを使っていてちょっと深掘りしたくなった話を書きます。

Glueを使っていたらNo space left on deviceが出た

普段私たちはダッシュボードに表示するデータを生成するために、GlueのETL Jobを利用して集めてきたデータを加工しています。ある日、開発中にGlueのJob実行が失敗し、どうやらストレージ不足で落ちていることが分かりました。ログを調べるとNo space left on deviceが出ています。

どのくらいのストレージを消費してしまったのか調べるために、Glueのworkerのストレージ容量を調べました。

  • Glueは複数台のworkerで動作する
  • 今回は10台workerがあった
  • 1台あたり44GBの空きがある*1
  • No space left on deviceは9つのworkerで出ていそう
    • 大量に出ていて重複もあるが、logStreamが別なので別々のworkerで出ていると判断

以上のことから、消費したストレージは44GB × 9 -> 400GB程度だと分かりました。

このジョブは1日に1回実行します。出力先はS3なので、当時はS3に毎日400GBのデータが増えることになる!?とかなり焦りました。

調査の結果、実装ミスであることが分かり、15GB程度の増加に収まる見込みで修正しました。 修正後Glueを実行すると、S3には50MB程度のparquetが出力されました。

あれ?15GBのはずでは!?と感じたのですが、これはparquetの内部で使っている圧縮処理の効果もあるかもしれないなと思いその場では納得しました。

そんなに圧縮されることある?

一件落着、と納得しつつも、心のどこかで「そんな圧縮されることある...?」という気持ちになります。ただ私は圧縮アルゴリズムに詳しくないので自信がありません。 そこで、この機会にparquetの構造やその圧縮率について調べてみることにしました。

parquetとは

parquetは元々hadoopエコシステム向けに開発された、列指向のファイルフォーマットです。AWSだと、すでに挙げたGlueの他にAthena, Redshift, SageMakerといったデータ分析系のサービスで使われることがあります。

ファイルフォーマットなので、どういう構造か解説します。

parquetのファイルフォーマットを示した図

まず、図のようにFile > Row group > Column chunk > Pageという入れ子構造になっていて、上位1つに対して下位は0~複数個持てるようになっています。

"列指向"のファイルフォーマットと書いたように、列の中身が圧縮されやすい形になっているColumn chunkが特徴的です。

例えば以下のようなJSON Linesをparquetに変換して保存することを考えます。

{"timestamp": "2026-07-01 ...", "user_id": 123, "status": "200"}
{"timestamp": "2026-07-01 ...", "user_id": 123, "status": "500"}
{"timestamp": "2026-07-01 ...", "user_id": 456, "status": "404"}

この時、データが大きい場合にはJSON Linesをそのままテキストとして圧縮するよりもparquetのように列指向フォーマットとして圧縮する方が圧縮率は良いとされています。 理由としては、テキスト圧縮に比べ、以下のようにparquetがencodingを効かせた後に圧縮するためです。

  • 圧縮単位としてstatusの一部のブロック(Page)を見れば良いのでstatus codeを辞書としてdictionary encoding
    • 200, 404, 500に0, 1, 2を割り当てて、その後の021...の列を圧縮する
  • timestampは差分が小さいのでdelta encoding
    • timestampが1000, 1005, 1010のように小さく増加する列の場合、初期値 + 小さな増加差分列として表現した後に圧縮できる

encodingの一覧はこちらを参照してください。

parquetの中で使われる圧縮

ここまでで、parquetは圧縮しやすくするためにencodingを事前にかけていること、テーブルデータに対してencodingがかけやすいように列指向のフォーマットになっていることが分かりました。

では実際圧縮するところではどういうアルゴリズムが使われているのでしょうか。

Compressionに扱える圧縮方式が書かれています。gzipのようなお馴染みのものや、brotliやzstdのような新しめの方式もあります。AWS GlueではデフォルトがSnappyで、zstd, brotliは選べません。*2

我々はGlueの中でSnappyを指定しているのでこれを調べました。 ざっくり言うとSnappyは速度重視で圧縮率はそれほどでもないアルゴリズムになります。他のgzip等と比較すると圧縮・展開が高速に行える点が特徴的です。*3 Glueのようなユースケースでなぜ高速性が重要視されるかというと、おそらくGlueにより読み書きされるparquetのファイルは中間データであることが多く、保存期間がそれほど長くないのが理由かなと思っています。仮にこれがGlueで大規模データを圧縮してS3 Glacierに置く、みたいな性質のデータだった場合はgzipを選択する可能性も出てくるのかなと思っています。通常は、S3に置くサイズを減らすより、Glueのコンピューティングリソースを使う時間が小さくなる圧縮方法を検討する方がバランスが良いでしょう。

Snappyのアルゴリズムを簡易的に説明します。 まず圧縮時についてです。 SnappyはLZ77系の圧縮アルゴリズムなので、以前に出現したbyte列を探して、見つかったらoffset + lengthで参照する動きをします。例えば以下のような形です。*4

abcabcabc = literal "abc" + copy(offset=3, length=3) + copy(offset=3, length=3)

ここで、literalは文字列、copy(offset,length)は文字列のコピーを表します。copy(offset=3, length=3)は現在位置から3文字前を3文字コピーする、という意味です。

copyがたくさん見つけられると嬉しい一方で、あまり良いcopyを探すことにこだわるとCPUリソースを使うというトレードオフがあります。これに対してSnappyはシンプルなアルゴリズムで動作します。*5

  • 現在位置から4byte分をハッシュ化してテーブルに現在位置を保存
  • テーブルに保存してあるハッシュと比較
  • すでにある→一致する4byteが過去にあるので、一致しなくなるまで伸ばしてCopy(offset, length)を書き込む
  • ない→テーブルにハッシュと位置を保存する

テーブルはhash→位置のmapになっているイメージですね。 また、matchがなかなか見つからない場合は、毎byte調べ続けるのではなく、徐々に探索間隔を広げてskipします。これにより、JPEGのようなすでに圧縮済みでmatchが見つかりにくいデータでは、無駄な探索を減らせます。*6

次に展開時についてです。 Snappyの圧縮アルゴリズムの逆をすれば良いので、純粋にCopyを展開していく処理になります。 他の圧縮アルゴリズム、例えばgzipはliteral/copyのような圧縮した結果をさらにHuffman符号化するので、展開時にもその処理を戻す必要があり、それに比べるとSnappyは高速に動作します。

実際に計測してみよう

では、実際のデータに近い状態でparquet(Snappy)がどの程度圧縮されるのか調べてみましょう。

利用状況ダッシュボードでは、ユーザID(user_id)をGlueでユーザIDの集合(user_id_set)に変換して中間表現としたのちに日/週/月単位のunique処理を入れています。それと似た状況を作ってみました。元データから中間表現への変換は以下のようになっています。

// 変換前
{
  "timestamp": 1714466727590,
  "type": "example/request",
  "version": "v1",
  "domain": "c000",
  "userId": 123,
  "metrics": { "endpoint": "/k/v1/app" }
}
// 変換後
{
  "timestamp": 1714466727590,
  "type": "example/request",
  "version": "v1",
  "domain": "c000",
  "user_id_set": [123, 1234, 12345],
  "endpoint": "/k/v1/apps",
  "number_of_requests": 28
}

変換前のJSONは50万行程度用意して変換後にparquet(Snappy)に変換しました。 その結果、parquetのファイルサイズは元々のJSON Linesの10%程度のサイズになりました。 内訳としては、user_id_set は6.8MB(元の90%程度)、domain, number_of_requestsは1MB以下、type, version, timestampは1KB以下になりました。user_id_setが支配的ですね。 アルゴリズムのところでも触れたように、繰り返しが多いversion, typeは圧縮率が高く、timestampはdelta encodingで圧縮率が高くなった一方で、高カーディナリティになっているuser_id_setは圧縮がほとんど効かないようです。

流石に400GBを用意することができなかったとはいえ、元々の疑問であった圧縮率については疑問が残ります。15GBが10%程度のサイズになると1.5GBのはずで、50MBになるのは桁が違います。仮にuser_id_setの影響がないとした場合も実験してみたのですが、そちらは1.5%になるようでした。15GBの1.5%は200MB程度なので、これでも説明がつきません。

まとめ: どうやら圧縮だけでは説明がつかなさそう

parquetを学んだことにより、圧縮だけでは説明がつかないぞという結論になりました。さらに調べたところ、以下が有力候補かなと思っています。

Glueはshuffle、sortのような処理を行う際に、メモリ上に乗らないサイズのデータを扱うため、Spark側の挙動としてメモリからストレージに一部渡してそこで処理をすることがあります。*7 option --write-shuffle-files-to-s3 によってこの退避先をS3に変更することもできます。 おそらく、今回400GBでNo space left on deviceになった理由はこれも影響しているのではないかと考えています。処理の中にはshuffle相当のものがあったので、そこでメモリでは処理し切れず、ストレージでも処理し切れなかったのではないかと考えています。

このような挙動はSpark UIで観測できるので、option --enable-spark-ui から有効化して次に起こったときには自信を持ってこれが原因だと言えるようにしようと考えています。

以上、Glueから圧縮に興味を持って調べたら、ある程度自信を持って原因が切り分けられるようになったという話でした。