ISUCON13に参加して最終スコア37,416で10X賞をいただきました
今年も @nari_ex、@kur_neko、@shiimaxx の3人で「( (0) / (0)) ☆祝☆」というチームで参加しました。
最終スコアは37,416で46位。目標のTOP30には届きませんでしたが10X賞をいただくことができました。ありがとうございます!
やったこと
インデックス追加
go-mysql-query-digestでスロークエリログを集計して、集計結果の上位のクエリでインデックスを追加して改善できそうなものがあったときに都度追加していました。最終的に追加したものは次のとおり。PowerDNSをMySQL バックエンドで最後まで使い続けたのでisudnsにもインデックスを追加しています。
isupipe
ALTER TABLE reactions ADD INDEX livestream_id_idx (livestream_id); ALTER TABLE livecomments ADD INDEX livestream_id_idx (livestream_id); ALTER TABLE livestream_tags ADD INDEX livestream_id_idx (livestream_id); ALTER TABLE icons ADD INDEX user_id_idx (user_id); ALTER TABLE themes ADD INDEX user_id_idx (user_id); ALTER TABLE ng_words ADD INDEX user_id_livestream_id_idx (user_id, livestream_id); ALTER TABLE reservation_slots ADD INDEX start_at_end_at_idx (start_at, end_at);
isudns
ALTER TABLE records ADD INDEX disabled_name_domain_id_idx (disabled, name, domain_id); ALTER TABLE records ADD INDEX disabled_type_name_idx (disabled, type, name);
getUserStatisticsHandler
とgetLivestreamStatisticsHandler
のN+1クエリ解消
getUserStatisticsHandler
内のランク算出の処理でユーザ毎にリアクション数とチップ合計を取得するN+1クエリがあったので、それぞれGROUP BYで集計して取得するように変更しました。同じようなN+1クエリがgetLivestreamStatisticsHandler
があったのでそちらも同じように修正しました。
DNS水責め攻撃対策
iptablesで存在しないドメインの名前解決のパケットをDROPするというアプローチを取りました。方針検討と実装の大半はnari_exにやってもらいました。
初期化処理で以下のスクリプトを実行してiptablesにルールを追加します。
#!/bin/bash # 冪等性を保つために既存のルールをflush sudo iptables -F INPUT # 登録済みのサブドメインを許可 mysql isudns -uisucon -pisucon -e 'select name from records' -s | sed 's/u.isucon.dev//' | tr -d . | grep -v '^$' | xargs -I%% sudo iptables -A INPUT -p udp --dport 53 -m string --string %% --algo bm -j ACCEPT # ".u.isucon.dev"を含むDNSリクエストをDROP(水攻め対策) sudo iptables -A INPUT -p udp --dport 53 -m string --hex-string "|017506697375636f6e0364657600|" --algo bm --from 41 --to 512 -j DROP # u.isucon.devを許可(INPUTチェインはpolicy ACCEPTなのでこのルールは不要) # sudo iptables -A INPUT -p udp --dport 53 -m string --hex-string "|7506697375636f6e0364657600|" --algo bm --from 41 --to 512 -j ACCEPT
POST /api/register
でサブドメインを追加するタイミングでそのサブドメインの名前解決を許可するルールを追加します。
if out, err := exec.Command("sudo", "iptables", "-I", "INPUT", "-p", "udp", "--dport", "53", "-m", "string", "--string", req.Name, "--algo", "bm", "-j", "ACCEPT").CombinedOutput(); err != nil { return echo.NewHTTPError(http.StatusInternalServerError, string(out)+": "+err.Error()) }
iptablesのルール追加をするPOST /api/register
はPowerDNSが動いているサーバで捌く必要があるので、Nginxで/api/register
の振り分けを固定します。
location /api/register { proxy_set_header Host $host; proxy_pass http://<PowerDNSが動いているサーバ>:8080; }
GET /api/user/:username/icon
で304を返す
アプリで実装するのが手軽そうだったので次のように実装しました。
iconsテーブルにsha256
というカラムを追加したうえでPOST /api/icon
でimageを保存するときにSHA256値を計算して保存しておきます。
imageHash := sha256.Sum256(req.Image) rs, err := tx.ExecContext(ctx, "INSERT INTO icons (user_id, image, sha256) VALUES (?, ?, ?)", userID, req.Image, fmt.Sprintf("%x", imageHash)) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to insert new user icon: "+err.Error()) }
GET /api/user/:username/icon
にIf-None-Match
ヘッダが含まれていてかつデータベース内のSHA256値と同一の場合は304レスポンスを返します。ヘッダ側に"
が含まれていて同一と判定されない問題がありましたがベンチマーク実行時にちゃんと304を返しているかを確認していたので早めに気づけて修正できました。
ifNoneMatch := c.Request().Header.Get("If-None-Match") ... if ifNoneMatch != "" { var hash string if err := tx.GetContext(ctx, &hash, "SELECT sha256 FROM icons WHERE user_id = ?", user.ID); err != nil { if errors.Is(err, sql.ErrNoRows) { return c.File(fallbackImage) } else { return echo.NewHTTPError(http.StatusInternalServerError, "failed to get user icon hash: "+err.Error()) } } if hash == strings.Trim(ifNoneMatch, `"`) { return c.NoContent(http.StatusNotModified) } }
fillLivestreamResponse
のN+1解消
IN句でまとめて取得するように修正しました。
inQuery, inArgs, err := sqlx.In("SELECT * FROM tags WHERE id IN (?)", tagIDs) if err != nil { return Livestream{}, err } var tagModels []TagModel if err := tx.SelectContext(ctx, &tagModels, inQuery, inArgs...); err != nil { return Livestream{}, err }
サーバ構成変更
最終構成はこれでした。1台目はNginxとPowerDNSのCPU使用率がそこそこ高かったのでAppの振り分けは3台目の比重を多めにしました。