ISUCON5本戦にてスコアトップの18万点でfailしました

  • 60
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

チーム.datとして、インフラ担当の@kannyと実装およびファシリテーション担当の@TakatoshiMaedaの若手連合で参加しました。

負けた内容が内容なので悔しくて悔しくて悔しいです。優勝スコアが15万だったので本当に悔しいです。

当日やったことを淡々と書いてみます。
今回の本戦については特段変わったことをしていないつもりですし、各チームがやっていて.datがやっていない施策も結構あった気がしています。

ISUCON5本戦課題について

時代はMicroserviceということで、雑に作られた複数のMicroserviceと連携するダッシュボードを最適化するという課題でした。
およそ下記のページを持っています。

path 内容
/login, /signup いわゆるログイン・登録系。ユーザーはメールアドレス、パスワードと利用プランを設定できて、cookieセッションにuser_id書き込んでログイン状態となります。
/ トップページ。利用プランに応じて各種APIを叩き、その結果を表示します。プランに応じて定期的にAjax的なAPI問い合わせをする仕組みになっていました。
/modify 各種API利用時に必要な各種パラメタの設定ページ
/data 各種APIを叩いた結果を返すjson api
/userjs js側に渡すパラメタを埋め込んだもの。 これにバグ混入させた、泣いた。

本戦では全く同じ状態に設定されたインスタンスを三台用意されるので、これらを最適化します。

当日やったこと

手元にあるメモに準じて解説します。

最優先事項: 一台でも高速化する施策

まずはgoの基本的な事項としていくつか設定をいれました。GOMAXPROCSの設定などですね。

import "runtime"

...省略...

runtime.GOMAXPROCS(runtime.NumCPU())

その他、

  • Content-Typeの適切な設定
  • 静的ファイルをnginxレイヤで返す
  • UnixDomainSocket化

が、再優先で実施されました。

その後、データ構造を眺めて、まず各種APIのエンドポイント情報を毎度DBから吸い出して利用していた部分にたいして、あらたなエンドポイントの登録系がないことを踏まえ、ソースコードに全てハードコードしました。下記は実際のコードです。

package main

import "net/http"

// 元の各レコード
// ken                 | GET  |            |                          | http://api.five-final.isucon.net:8080/%s
// ken2                | GET  |            |                          | http://api.five-final.isucon.net:8080/
// surname             | GET  |            |                          | http://api.five-final.isucon.net:8081/surname
// givenname           | GET  |            |                          | http://api.five-final.isucon.net:8081/givenname
// tenki               | GET  | param      | zipcode                  | http://api.five-final.isucon.net:8988/
// perfectsec          | GET  | header     | X-PERFECT-SECURITY-TOKEN | https://api.five-final.isucon.net:8443/tokens
// perfectsec_attacked | GET  | header     | X-PERFECT-SECURITY-TOKEN | https://api.five-final.isucon.net:8443/attacked_list

var endpoints = map[string]*Endpoint{
    "ken": &Endpoint{
        "ken", "GET", "", "", "http://api.five-final.isucon.net:8080/%s", http.DefaultClient,
    },
    "ken2": &Endpoint{
        "ken2", "GET", "", "", "http://api.five-final.isucon.net:8080/", http.DefaultClient,
    },
    "surname": &Endpoint{
        "surname", "GET", "", "", "http://api.five-final.isucon.net:8081/surname", http.DefaultClient,
    },
    "givenname": &Endpoint{
        "givenname", "GET", "", "", "http://api.five-final.isucon.net:8081/givenname", http.DefaultClient,
    },
    "tenki": &Endpoint{
        "tenki", "GET", "param", "zipcode", "http://api.five-final.isucon.net:8988/", http.DefaultClient,
    },
    "perfectsec": &Endpoint{
        "perfectsec", "GET", "header", "X-PERFECT-SECURITY-TOKEN", "https://api.five-final.isucon.net:8443/tokens", http.DefaultClient,
    },
    "perfectsec_attacked": &Endpoint{
        "perfectsec_attacked", "GET", "header", "X-PERFECT-SECURITY-TOKEN", "https://api.five-final.isucon.net:8443/attacked_list", http.DefaultClient,
    },
}

type Endpoint struct {
    Service   string
    Method    string
    TokenType string
    TokenKey  string
    URI       string

    client *http.Client
}

この時点で最もアクセス数が多い /dataにて認証以外のクエリを殺すことができました。

その後、Goの利点を活かし、最大7エンドポイントへのリクエストを並列化しました。
goroutineとsyncパッケージを使えば楽勝ですね。

//...省略...
    data := make([]Data, 0, len(user.Arg))
    var wg sync.WaitGroup // waitgroupを使って全てのAPI問い合わせ完了をまつ
    m := &sync.Mutex{}    // mutexを使ってdataのappend処理の競合を避ける
    for s, c := range user.Arg {
        service := s
        conf := c
        wg.Add(1)
        go func() {
            defer wg.Done()
            e := endpoints[service]
//...省略...
           d := fetchApi(e.Method, uri, headers, params)
            m.Lock()
            data = append(data, Data{service, d})
            m.Unlock()
        }()
    }
    wg.Wait()

また、その後ユーザーデータとAPIアクセス用のパラメタデータをRedisに載せる処理を用意しました。
ここは下記のようなデータ構成にして、1ユーザーの認証・ログインにおいて問い合わせ回数を採用にする + できるだけデータのパースにコストを使わないようにしました。
ユーザーIDの払い出しにもRedisを利用していて、incrを使ってある程度安全にユーザーIDを生成しています。

ユーザー情報
Type: string型
Key: user:<userID>
Value: id,email、API問い合わせ情報パラメタのタブ区切り文字列

ログイン用情報
Type: string型
Key: user:pass:<email>:<pass>
Value: ユーザーのid

ユーザーID払い出し用
Type: string
Key: userid
Value: 現在のmax id

プリセットされていたユーザー情報については上記に合わせてtsvを生成し、/initializeを叩くときに初期化するようにしました。
この辺までの実装はほぼ自分が全て担当し、14時過ぎには終えております。

負けた原因も多分ここにあって、
恐らくこのデータに一行だけミスが有って、最終計測でもuser.jsの生成に1リクエストだけfail、その結果failしました。
ベンチマーカーは途中までこのバグを無視する状態になっていたので、対処が後手に回りました。(というかほぼ無視に近かった…)

上記施策を淡々と実施した結果、16時頃の時点で単一インスタンスのみで12万点を突破しています。

score

複数台のリソースを使い切るための施策

その後、冷静に、かつサーバの気持ちになって htopを眺め インスタンス内の状況を分析し、下記の内容を元に施策に踏み切りました。

  • jsの編集は許されないのでリクエストは一台で受ける必要がある
  • 200の加点は高いので静的ファイルはできるだけ早く返す必要がある
  • 20万点目指すなら1分間に20万リクエストを単体nginxで捌くことになる。CPUをできるだけ表層のnginxに寄せたい
  • 1つのRedisをサーバ間で参照するようにしたい
  • ログはすべて切る

施策としては、3台のサーバを下記の分担にしています。

サーバ 内容
1号 nginx + 静的ファイル配信、動的ページを全て2号機、3号機をupstreamとしてproxyする
2号 nginx + Golang + Redis
3号 nginx + Golang、Redisは2号機を参照する

上記構成でもまだ1号機以外のCPUは振り切らなかったので、後述するgzip処理入れても良かったなぁという反省が有ります。

これらを完了しベンチした結果は16万点を超えていました。
18時での提出はここで終えています。

当日、作ったけどマージせずに終わったもの

  • text/templateの廃棄
    • text/templateの負荷は割と無視できないレベルなので外します
    • こちらはメンバー間のレビューから漏れてしまいマージされず、最後にマージしようとしましたが安全を優先
  • 7種のAPI内容のキャッシュ
    • 他チームはここに重きをおいていた模様
    • 結局並列化しても一番おそいAPIに縛られる + 値の変更が頻繁なAPIばかりだと効果薄いので最後に回しました
  • Redisを捨ててGoのプロセスキャッシュ化 + gobバックアップ
    • 予選で実施した作戦です
    • Redis => goのオブジェクトに変換するコストを減らせるので圧倒的なCPUコスト減になるのでこれはやりたかった
    • ただ、認証などの構成が少し面倒になるので後回しになり、安全を優先して実施せず

他チームがやっていて、できていないもの

  • Gzip化
    • APIレスポンスやcss, jsの配信はgzip化しても良かったものと思われます。
    • 完全に忘れていた

感想

@kannyさんの各API分析力とか@TakatoshiMaedaの事前準備+フォロー力にだいぶ安心して背中任せつつ淡々と施策を打てての18万点だったので、確実にチームとしての実力が上がっていることを実感しつつ、最後の自分の詰めの甘さに本当に悔しい思いをしました。完全に甘えです。20万点も行けるはずだったので来年こそはチーム全体の実装力上げて勝つ。

色々な方に期待されていたようで余計に悔しさがのこります。

最後に

予選、本戦と@tagomorisさん、@kamipoさん櫛井さん始めとした運営の皆さん本当にお疲れ様でした。
ベンチマーカーやポータルサイト、問題の質などどれをとっても最高でした。
多分参加した全ての方が楽しめたんじゃないかと思います。
ありがとうございました。