multipart/form-dataの省メモリかつ高速なパーサー「FormStream」
ファイルのアップロード時などにmultipart/form-data形式を使用することが多いと思います。
Go言語では標準ライブラリのmime/multipart
パッケージを使ってパースができます。
しかし、実はmime/multipart
パッケージには落とし穴があり、気を付けないと速度低下やメモリ使用量増加につながります。
この記事では、traPでのサービスでのファイルアップロード速度の改善のためにFormStreamというライブラリを作り、multipart/form-dataを高速かつ省メモリなパースを簡単に実現した話をします。
traP Collection
traP CollectionはtraPでサークル内で開発されたゲームの販売・展示を行うゲームランチャーです。
知っている方はSteamをイメージするとわかりやすいのですが、部員が開発したゲームをアップロードするとランチャーを持っている人がそのゲームをダウンロードして遊べるようになるアプリケーションです。
このtraP Collectionではmultipart/form-dataを利用してファイルアップロードを行っています。
また、traP Collectionのサーバーはコストの関係でメモリサイズが1GBと少ないインスタンスで運用しています。
このことから、メモリを使用しなくとも高速なmultipart/form-dataパーサーの需要がある状況でした。
このような状況で、Goの標準ライブラリのコードを読み込んでいたところ、改善案を思いついたため作成したのが今回紹介するFormStreamです。
multipart/form-data
本題に入る前にmultipart/form-dataのフォーマットについて簡単に説明します。
multipart/form-dataは、テキストとバイナリデータの両方を含むことができ、各Partに対してヘッダ情報を付与することができます。ヘッダには、フィールド名やファイル名、Content-Typeなどの情報が含まれています。
Partは --boundary
のような境界文字列で区切られており、リクエストの最後には --boundary--
と記述されます[1]。
multipart/form-dataの構造は以下の例のようになります。
--boundary
Content-Disposition: form-data; name="name"
mazrean
--boundary
Content-Disposition: form-data; name="password"
password
--boundary
Content-Disposition: form-data; name="icon"; filename="icon.png"
Content-Type: image/png
icon contents
--boundary--
ここで重要な点として、multipart/form-dataを処理する際にはPartの順序に関わらず処理できるべきということがあります。
RFC 7578[2]では順序が明確なフォームの場合はその順序でPartを送るべきとなっていますが、SHOULD
であるため確実に守られると考えるのは危険です。
また、自然な順序が定まらないフォームも存在する点に注意するべき、と記述されています。
これらを鑑みると、Partの順序に関わらず処理できるコードを書くべきと考えられます。
mime/multipart
Go言語の標準ライブラリのmime/multipart
パッケージではReader構造体を用いてmultipart/form-dataをパースできます。
Reader構造体にはPart読み取りのための2種類のメソッドがあります。
NextPart
NextPart
は、multipart/form-dataの各Partを順次読み取るためのメソッドです。
ストリーミング処理が可能でメモリ使用量を抑えつつ高速に処理することができますが、前から順にPartを処理していくため、ファイルのPartを処理する際に他のPartの情報を使いたい場合に処理が複雑化します。
実装例
multipart/form-dataの例に対して「iconを含むユーザー情報を保存する処理」をPartの順序に関わらず処理できるように記述すると、以下のよう[3]になります。
ユーザー情報の保存に使いたいデータがそろったタイミングでsaveUser
を実行する必要があるため、かなり複雑になっているのが分かると思います。
また、この実装には、「icon
が小さい場合はメモリに一時保存するべき」、「icon
Partを多数含むmultipart/form-dataによりファイルディスクリプタが使い果たされる脆弱性[4]」などの問題があります。
これらに対処すると、さらに複雑な実装になります。
mr := multipart.NewReader(r, boundary)
fieldMap := make(map[string]string)
var icon io.Reader
LOOP:
for {
p, err := mr.NextPart()
if err == io.EOF {
break
}
switch p.FormName() {
case "name", "password":
sb := &strings.Builder{}
io.Copy(sb, p)
fieldMap[p.FormName()] = sb.String()
// icon、name、passwordがそろっているならsaveUser実行
name, nameOk := fieldMap["name"]
password, passwordOk := fieldMap["password"]
if nameOk && passwordOk && icon != nil {
savaUser(name, password, icon)
break LOOP
}
case "icon":
name, nameOk := fieldMap["name"]
password, passwordOk := fieldMap["password"]
if !nameOk || !passwordOk {
// name、passwordがそろっていないなら一時保存
f, _ := os.CreateTemp("", "formstream-test-")
defer f.Close()
io.Copy(f, p)
icon = f
} else {
// name、passwordがそろっているならsaveUser実行
saveUser(name, password, p)
break LOOP
}
}
}
ReadForm
ReadForm
は、multipart/form-dataの全てのPartをメモリまたはディスクに一時保存した上で処理するメソッドです。
ファイルサイズの合計が一定値(32MB)になるまではメモリにデータを保持し、超えたものをディスクに保存します。
Partの順序に依存せず処理できる反面、メモリ使用量が増えるほか、ファイルサイズが大きい場合ディスクへの一時保存が発生するため速度も低下します。
HTTPサーバーを構築する際によく使うEchoやGin、標準ライブラリのnet/http
などではこちらが使用されています[5]。
実装例
NextPart
と同様に、multipart/form-dataの例に対して「iconを含むユーザー情報を保存する処理」を記述すると、以下のよう[3:1]になります。
NextPart
と比べるとかなりシンプルに記述できている代わりに、Partの順序に関わらずメモリかディスクにファイルが一時保存され、パフォーマンスが低下します。
// メモリに保持する最大のファイルサイズ(ここでは32MB)
const maxMemory = 32 * (1 << 20)
mr := multipart.NewReader(r, boundary)
form, _ := mr.ReadForm(int64(maxMemory))
defer form.RemoveAll()
f, _ := form.File["icon"][0].Open()
defer f.Close()
saveUser(form.Value["name"], form.Value["password"], f)
FormStream
これらを踏まえて、FormStreamではNextPart
をラップし、必要な場合のみメモリまたはディスクに一時保存する処理をライブラリとして提供しています。
これによって、ReadForm
同様にPartの順序に関わらずに処理しつつ、ストリーム処理が可能な際はNextPart
同様に省メモリかつ高速に処理が可能になっています。
具体的には以下のような処理を行います。
- ファイルのPartの前に必要なPartがそろっている
→ストリーム処理-
NextPart
と同様
-
- ファイルのPartの前に必要なPartがそろっていない
→必要なPartがそろうまでメモリまたはディスクに一時保存、そろい次第ファイルのPartを処理-
ReadForm
に近い
-
実装例
multipart/form-dataの例に対する処理は以下のよう[3:2]になります。
ハンドラーを設定する形式のAPI[6]として、ライブラリ側でファイルのPartに対する処理の実行タイミングをライブラリが制御できるようにすることで、NextPart
と比べてかなり簡単にこのような処理を記述できるようになっています。
parser := formstream.NewParser("boundary")
// iconのハンドラー設定(この時点では実行されない)
parser.Register("icon", func(r io.Reader, header formstream.Header) error {
// RequiredPartを設定しているので、name, passwordは確実に存在
name, _, _ := parser.Value("name")
password, _, _ := parser.Value("password")
// userの保存を実行
return saveUser(r.Context(), name, password, r)
}, formstream.WithRequiredPart("name"), formstream.WithRequiredPart("password")) // iconの処理にname, passwordが必要なことを示す
// パースの実行
// この最中にハンドラーが実行される
parser.Parse(buf)
ベンチマーク
ライブラリ単体
FastPathがうまくストリーム処理できたとき、SlowPathがPartの順序の関係でストリーム処理できずメモリまたはディスクへの保存が行われた際の結果です。
標準ライブラリのNextPart、ReadFormを比較対象としています。
いずれも対数軸な点に注意してください。
ベンチマーク環境
- OS: Ubuntu 22.04.2 LTS(WSL2 on Windows 11 Home)
- CPU: AMD Ryzen 9 7950X 16-Core Processor
- メモリ: 32GB
- ディスク: SSD 2.5T
- Go version: 1.22.0
- ベンチマークコード(2024/3/30 追記): https://github.com/mazrean/formstream/blob/5335f06fd778cce8a433ed55c89bec3b684a125e/formstream_test.go
- グラフ化はリポジトリ中のscript/graph.pyで行っています
メモリ使用量
FastPathではReadForm・SlowPathよりメモリの使用量が大幅に少なくなっています。
また、主題とは逸れるため説明を省いているのですがsync.Pool
によるバッファーの再利用などを行っている関係で、FastPathではNextPartを使用した場合と比べてもメモリの使用量が少なくなっています。
実行速度
FastPathでNextPart、SlowPathでReadFormとほぼ同じ実行速度となっています。
また、FastPathがSlowPathより大幅に速くなっており、想定通りの実行速度となっています。
アップロード速度
部員用Web UIからの約300MBのファイルのアップロード速度を計測しています。
beforeの方で合計時間を含め忘れたのでわかりづらいですが、ReadFormを使用した実装では40秒程度かかっていたのが、レスポンス待機時間が大幅に減って23秒程度で終わるようになり、大幅に改善しています[7]。
ストリーム処理が機能し、大幅に高速化できていることが分かると思います。
before
after
まとめ
可能な限りストリーム処理が可能かつ、Partの順序に関わらず処理可能なmultipart/form-dataパーサーライブラリformstreamを紹介しました。
multipart/form-data形式をファイルに書き出さずに処理したい場合などにぜひ活用してみてください!
-
boundary
の部分はランダム文字列になります ↩︎ -
https://datatracker.ietf.org/doc/html/rfc7578#section-5.2 ↩︎
-
過去に
ReadForm
でCVE-2022-41725として報告された脆弱性 ↩︎ -
参考
-
net/http
: ReadFormを使用 - Echo:
net/http
を使用 - Gin:
net/http
を使用
-
-
このAPIの変更がポイントとなため、標準ライブラリをの実装を改善する形では対処しきれなくなっています ↩︎
-
ここまで遅いのはネットワーク帯域100Mbpsという貧弱なインスタンスでサーバーが動いているのが原因です ↩︎
traP (デジタル創作同好会 traP)は、 東京科学大学で最も活発なデジタル創作・プログラミング系サークルです。Zenn 上では主にメンバーによる技術ブログを掲載していますが、普段はホームページ上で投稿しています。こちらもぜひご覧ください。trap.jp
Discussion