🆙

multipart/form-dataの省メモリかつ高速なパーサー「FormStream」

2024/03/18に公開

ファイルのアップロード時などにmultipart/form-data形式を使用することが多いと思います。
Go言語では標準ライブラリのmime/multipartパッケージを使ってパースができます。
しかし、実はmime/multipartパッケージには落とし穴があり、気を付けないと速度低下やメモリ使用量増加につながります。
この記事では、traPでのサービスでのファイルアップロード速度の改善のためにFormStreamというライブラリを作り、multipart/form-dataを高速かつ省メモリなパースを簡単に実現した話をします。
https://github.com/mazrean/formstream

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が小さい場合はメモリに一時保存するべき」、「iconPartを多数含む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を比較対象としています。
いずれも対数軸な点に注意してください。

ベンチマーク環境

メモリ使用量

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形式をファイルに書き出さずに処理したい場合などにぜひ活用してみてください!

脚注
  1. boundaryの部分はランダム文字列になります ↩︎

  2. https://datatracker.ietf.org/doc/html/rfc7578#section-5.2 ↩︎

  3. エラー処理は省いています ↩︎ ↩︎ ↩︎

  4. 過去にReadFormCVE-2022-41725として報告された脆弱性 ↩︎

  5. 参考

    ↩︎
  6. このAPIの変更がポイントとなため、標準ライブラリをの実装を改善する形では対処しきれなくなっています ↩︎

  7. ここまで遅いのはネットワーク帯域100Mbpsという貧弱なインスタンスでサーバーが動いているのが原因です ↩︎

traP

Discussion