サーバーサイドの画像編集処理でメモリ使用量を抑える方法
こんにちは、開発チームの山下です。
業務ではスーパーデリバリーの開発を担当しています。
今回は私がスーパーデリバリーの開発で関わったサーバーサイドにおける画像編集処理でメモリ不足を防ぐノウハウを共有したいと思います。スーパーデリバリーではユーザーが任意の画像をアップロードできる機能があるため、まれに想定以上の高解像度画像が処理されてサーバーが Out Of Memory エラーを発生させることがありました。
そこで私が任されたタスクが「画像編集処理でメモリ不足を避けるために、バリデーションを追加する」です。
TL;DR
サーバーサイドでの画像編集はメモリ使用量が高い操作です。
なぜなら処理される画像データはメモリ上に非圧縮のビットマップとして展開されるためです。
サーバー上でのメモリ不足の発生を避けるために画像のヘッダ情報を使ってメモリ使用量を見積り、事前に処理を停止するなどの判断ができます。
副部長(テクニカルディレクター)に突っ込まれた話
私はタスクの内容を見て、画像をアップロードする画面にファイルサイズのバリデーションを追加するだけでいいと思っていました。そこでファイルサイズのバリデーションを実装していると、弊社の副部長 (このような記事を書く方です)から下記のようなご指摘をいただきました。
OOM が発生する巨大な画像はファイルサイズに依存していない可能性があります。
結局問題は画像処理の過程で圧縮された画像データをビットマップでメモリ上に展開する部分です。
7000x7000のRGB画像(ビット深度は各8bit)がアップロードされたら、7000x7000 の配列をRGBの3つ分、確保する必要があるので7000x7000x3 = 147,000,000 で、ざっと140MBくらいになります。
問題は 7000x7000 という巨大画像でも数百KBしかないケースがあることです。
imagemagick がインストールされている環境なら次のコマンドで7000x7000のPNGを簡単に生成できます。こちらで試した際にはサイズはたったの157KBでした。
convert -size 7000x7000 xc:#ffffff PNG24:7000x7000.png
これは極端な例ですが、普通の画像でも圧縮率を上げればサイズは小さくなります。
この問題への対処方法はサーバーサイドでメタデータを元に縦横比を取得して、面積が一定サイズ以上になったらの処理を行わないことかなと思いました。
ご指摘いただいた「画像編集処理でのメモリ使用量はファイルサイズだけではなく解像度も関係してくる!」ということを知らなかったので、画像の解像度とメモリ使用量について調べつつ今回の記事を書きました。
実測した結果
高解像度の画像に対して編集処理を行うと本当にメモリ使用量が高くなるか検証しました。
実行環境はGoの1.20を使っています。
サンプルコードは下記です。
サンプルコードでは、前述の方法で7000×7000のPNGファイルを作成した画像を70×70にリサイズしています。
また、比較のために空のコードも作成しました。
そして、Goのtestingパッケージ内に含まれているベンチマークを取得する仕組みを使って該当のコードを100回実行したときの平均メモリ使用量を確認しています。
main.go
package main
import (
"image"
_ "image/png"
"os"
"github.com/disintegration/imaging"
)
// 画像をリサイズする
func ResizeImage(r image.Image) image.Image {
return imaging.Resize(r, 70, 70, imaging.Lanczos)
}
func OpenAndDecodeImage(path string) (image.Image, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
img, _, err := image.Decode(file)
return img, err
}
func main() {
// メインの処理
}
main_test.go
package main
import (
_ "image/png"
"testing"
"github.com/disintegration/imaging"
)
func BenchmarkResizeImage(b *testing.B) {
for i := 0; i < b.N; i++ {
f, openErr := OpenAndDecodeImage("7000×7000.png")
if openErr != nil {
b.Errorf("OpenAndDecodeImage() error = %v", openErr)
}
resizedImage := ResizeImage(f)
resizeErr := imaging.Save(resizedImage, "resized.png")
if resizeErr != nil {
b.Errorf("imaging.Save() error = %v", resizeErr)
}
}
}
func BenchmarkEmpty(b *testing.B) {
for i := 0; i < b.N; i++ {
}
}
実行結果は下記です。
BenchmarkResizeImage-8 100 1241852441 ns/op 200838831 B/op 113 allocs/op
BenchmarkEmpty-8 100 9.080 ns/op 0 B/op 0 allocs/op
この結果から、メモリは平均200MB程度使っていることが分かります。
理論値として計算していた 7000x7000x3 ≒ 140MB を十分保持できる 200MB というメモリ使用量になったことを確認できました。
なぜ起きるのか
なぜ、ファイルサイズが小さくても解像度が高ければメモリ使用量は高くなるのでしょうか?
画像には圧縮されていないときのサイズと、圧縮されているときのサイズが存在します。
ビット深度が24bitの場合、圧縮されていないときのサイズは基本的に下記のような式で求められます。
画像のデータ量 = 横のピクセル数 × 縦のピクセル数 × 1ピクセルの情報量(1~3バイト)
カラー画像では赤・緑・青の3原色についてそれぞれ明るさを1バイトで表現することが多いです。その場合、1画素は3バイトで表現されるため1ピクセル当たりの情報量は3バイトになります。
スマホで写真を撮ると、データ圧縮する技術が使われるため上記の式で計算されるファイルサイズよりかなり小さくなります。
データ圧縮方法にはPNGファイルで使われる「可逆圧縮」とJPEGファイルで使われる「非可逆圧縮」があります。「可逆」とは元の状態を完全に再現できるという意味です。
可逆圧縮では画像から似たような信号を見つけて「ここからここまではこの色!」と表現する方法です。
非可逆圧縮では、可逆圧縮の方法と合わせて人の視覚では見分けられないような微妙な差異や特徴を切り落とすことで圧縮効率を高めます
出典:画像解析入門: 画像データの基礎知識
画像編集処理では圧縮される前の画像データを利用するので、メモリには圧縮前のビットマップが展開されます。そのためJPEGやPNGなどの形式で圧縮されたファイルサイズがどれだけ小さくても、高解像度の画像であれば使われるメモリは多くなります。
画像フォーマットはヘッダ部にサイズのってるZe
上記で示したように画像の縦横ピクセル数が分かればメモリ使用量を見積もることが出来ます。
また、画像編集処理でサーバがメモリ不足にならないように画像の縦横ピクセル数でもバリデーションをする必要性があります。画像の縦横ピクセル数が一定値を超えたら処理をしないなど。
どこを見れば画像の縦横ピクセル数を知ることが出来るのでしょうか?
JPEG、PNGファイル構造はそれぞれ独自の構造を持っていますが基本的にはType-Length-Value (TLV)形式に似た形式になっています。
TLV形式とは通信プロトコルなどで用いられる汎用的なデータ記述形式の一つで、データの種類(Type)、長さ(Length)、値(Value)を連結したものです。
出典: IT用語辞典: TLV形式
そして、JPEG、 PNGでは画像のサイズもヘッダ情報に載っています。
サンプルコードは下記でメモリ使用量を確認しました。
こちらも該当のコードを100回実行したときの平均メモリ使用量を確認しています。
main_test.go
package main
import (
"encoding/binary"
_ "image/png"
"os"
"testing"
)
func BenchmarkReadPngHeader(b *testing.B) {
for i := 0; i < b.N; i++ {
f, err := os.Open("7000×7000.png")
if err != nil {
b.Errorf("Open File error = %v", err)
}
defer f.Close()
rawHeader := make([]byte, 24)
_, err = f.Read(rawHeader)
if err != nil {
b.Errorf("Read Png Header error = %v", err)
}
// 画像の幅を取得
binary.BigEndian.Uint32(rawHeader[16:20])
// 画像の高さを取得
binary.BigEndian.Uint32(rawHeader[20:24])
}
}
実行結果は下記です。
BenchmarkReadPngHeader-8 100 639716 ns/op 215 B/op 4 allocs/op
この結果から、メモリ使用量は平均215Bで済んでいることが分かります。
上記の結果から、解像度の高い画像であっても圧縮前の画像全体を読み込まなくても縦横ピクセル数を取得することが出来ることが分かりました!!
今回のサンプルコードではPNGファイルのバイナリを解析して縦横ピクセル数を取得しましたが、プロダクション環境で利用する場合は画像処理ライブラリに用意されている画像のメタデータを取得する方法を利用しましょう。
また、Webアプリケーションで画像編集処理を行う場合は、ミドルウェアの同時接続数設定に準じて同時に複数の画像編集処理が実行されます。
そうすると個々の処理ではメモリ使用量を制御できても依然として Out Of Memory が発生する可能性があります。そこで画像処理専用のスレッドを固定数で用意するなど、同時に処理する画像の枚数を制限する機構を用意してあげると良いでしょう。JavaであればThreadPoolExecutorなどがあります。
まとめ
今回は画像編集処理におけるメモリ使用量、画像バリデーションの必要性について書きました。
この記事で画像編集処理はメモリ使用量が多いこと、ファイルサイズのバリデーションのみでは不十分なことを理解していただければ幸いです。
最後になりましたが、ラクーングループは一緒に働く仲間を絶賛大募集中です!
原理を知ることの大切さをとても推奨される会社です。弊社のつよつよなエンジニア達に様々な助言をもらいながらエンジニアとして腕を磨きませんか?
ご興味を持っていただけましたら、こちらからエントリーお待ちしています!