Go: Argon2id のハッシュ計算はセマフォで同時実行数を絞る(ログイン集中でメモリが溢れるのを防ぐ)
Argon2id はハッシュ1回ごとにパラメータ m 分のメモリを確保する。ログインが同時に来るとその分だけ積み上がるので、バッファ付きチャネルで同時に計算する数に上限を付ける。
何が起きるか
m=64 MiB で設定した場合、同時に処理中のログイン1件につき 64 MiB を使う。投稿者の負荷試験では、128 件を並列に流すと約 7.5 GB のメモリを使った一方、処理できたのは毎秒約 75 件で、8 件並列のときと変わらなかった(数値は投稿者の環境での測定)。
CPU を使い切った時点で、それ以上並列にしても速くならず、メモリだけが増える。ログインの集中やパスワード総当たり攻撃のときに OOM でプロセスが落ちる原因になる。
やり方
バッファ付きチャネルをセマフォとして使い、ハッシュ計算の入口で枠を取る。待ちが長くなりすぎないよう、リクエストの context でも抜けられるようにする。
go
import (
"context"
"runtime"
"golang.org/x/crypto/argon2"
)
// 同時に計算する数。目安は NumCPU/4 程度から負荷試験で決める
var hashSlots = make(chan struct{}, max(1, runtime.NumCPU()/4))
func hashPassword(ctx context.Context, password, salt []byte) ([]byte, error) {
select {
case hashSlots <- struct{}{}: // 枠を取る
case <-ctx.Done():
return nil, ctx.Err() // 待ちきれなければ 503 などで返す
}
defer func() { <-hashSlots }() // 枠を返す
// time=1, memory=64*1024 KiB(64 MiB), threads=4, keyLen=32
return argon2.IDKey(password, salt, 1, 64*1024, 4, 32), nil
}- ピーク時のメモリは「上限 × m」で見積もれるようになる(上の例なら NumCPU/4 × 64 MiB)。
golang.org/x/sync/semaphoreのWeightedを使っても同じことができる(Acquire(ctx, 1)/Release(1))。
注意点
- 上限の目安(NumCPU/4)は投稿者の提案。threads(並列度 p)の値やほかの処理との兼ね合いで変わるので、自分の環境で負荷試験をして決める。
- 枠待ちが長いとログイン全体が遅くなる。待ち時間の上限(context のタイムアウト)と、ログイン試行のレート制限を合わせて入れる。
- パスワード照合(保存済みハッシュとの比較)も同じ関数を通すこと。照合側だけ素通しだと意味がない。比較には
subtle.ConstantTimeCompareを使う。 - パラメータ(m / t / p)の選び方は security 分野の パスワードハッシュは「アルゴリズム+コストパラメータ」まで決める(Argon2id / scrypt / bcrypt / PBKDF2) を参照。
- Go 1.21 以降は組み込みの
maxが使える。それより前なら自前で書く。