ISUCON9予選の出題と外部サービス・ベンチマーカーについて

今回のISUCON9予選は『ISUCARI』という椅子限定のフリマサービスでした。実際に存在するサービスがモデルになっていることもあり、どこまで再現するか悩みました。ここは覚えている限り振り返っていきます。

なおこのエントリーは私個人の視点でのエントリーなので、過去の経験などについても触れます。

なお解説と講評がすでに公開されているので、そちらを読んでからの方が楽しめると思います。

追記(2019/10/21)

こちらの記事も参考になると思います。

ISUCON6のリベンジ

私はISUCON6本選の運営も担当しています。短期間で2度運営をやった人は数えられるくらいしかいないと思いますが、様々なタイミングが重なり、再び運営に携わることになりました。

ISUCON6の時はまずISUCONの運営はどうすればいいのか全く分からなかったので、まず社内ISUCONという形で以下のリポジトリを作りました。

この時に様々な失敗をし、その反省と知見がISUCON6本選に活かされました。そのときの話は以下に色々書いています。

この時にも様々な反省と知見を得ていました。実はこの時に口外はしませんでしたが、自分の年齢を考えるとまた近いうちに出題の話が回ってきそうな予感がありました。そして次のチャンスがもしあればこうしたい、というようなアイディアもいくつか持っていました。簡単なものを挙げると以下のものです。

  • isucon/isucon9-qualify 以下にリポジトリがある前提で最初から作る
  • transfer repositoryでそのまま公開されることを最初から宣言しておく

ISUCON9の出題の話をもらったときに、誰にも説明はしていませんでしたが、自分の中では以下のことを実現しようと思っていました。

  • 次のISUCONのデファクトスタンダードになる
  • 社内の研修などにも使いやすい問題にする

これらのことを念頭におきながら作業を進めていきました。

アイディア出し

5月末に一度運営チームで打ち合わせをして、アイディア出しをしていました。そのときにいくつか案は出たものの、一番おもしろくできそうだったのはやはりフリマサービスだったので、そちらに決定しました。その時点で今回実装したものの方向性はおおむね決定していました。本当にいろんなものが決定していましたが、具体的にいくつか書き出してみます。

  • トランザクション中に外部APIにリクエストをして詰まる
  • 配送状況の確認APIに無駄にリクエストを送る
  • 売り上げがスコアになる
  • (前回の本選を参考にして)キャンペーン機能でリクエスト数を増やす仕組み
  • 最初はN+1があって、それを解消すると他が詰まる
  • 最初は安い商品が出品されて、捌けてくると高い商品が出品される
  • 1つの商品に購入が偏る瞬間がある
  • transaction_evidencesを保存しておいて、ベンチマーカーはそれを検証する
  • ユーザーの行動を調べると売り上げを上げる方法が分かる

こういうところがざっくり決まり、具体的な仕様を自分が詰めていくことになりました。

webappの最初の実装

ISUCON予選問題は以下のことが求められると思っています。

  • 初学者が学ぶ教材として優れている
  • 他言語に移植しやすい実装

これらを実現するために以下のことを早い段階で決定しました。

  • パスワードをbcryptを使って保存
  • JSON APIを提供してフロントエンドはSPAで提供

まず最初はGoで実装を作り始めました。依存管理はもちろんGo Modulesを使います。ISUCONだと他の言語実装と同じ構成になるのでGOPATHとの相性が悪かったですが、Go Modulesを使うとmainパッケージしかない実装ならばGOPATHと無縁でいられます。ISUCONだととても重要な機能です。これによって例年のGo実装は変な工夫をしていましたが、そういったことが一切不要になっています。

フロントエンドを自分が作る気はなかったので、とにかくコアの機能である出品から取引が完了する一連の流れを全て行えるように実装していきました。紆余曲折はありましたが、最終的な流れはISUCARI アプリケーション仕様書に書かれているものに落ち着きました。ここに行き着く前に外部サービスの仕様を決める必要があったので、次に外部サービスの仕様について書いていきます。

外部サービスについて

外部サービスは今回の問題の肝でした。しかし過去のISUCON本選では何度か外部サービス・マイクロサービスが出ていますが、予選では過去に出題されたことはありません。これは以下の理由によるものと考えています。

  • 予選の規模で安定した別アプリケーションを提供することは困難
  • 別アプリケーションになっている必要性を見いだしにくい

これを解決するために以下のことを考えました。

  • ベンチマーカーが外部サービスを直接提供することで各チーム専用の外部サービスを提供する
  • ISUCON8本選同様にinitializeの処理で外部サービスのドメインを外から指定するようにする
  • 他チームからの外部サービスへの攻撃や書き込みを避けるためにIPアドレス制限を入れる
  • 実際のサービスがモデルになっているので必要性は明白
  • 仕様を本物に寄せることで実際のサービスを開発している気持ちになりエンターテイメント性を高める

これを実現するために以下の実装をすることに決めます。

  • ベンチマーカーと同じGoで実装する

外部サービスはpayment serviceとshipment serviceの2つがあるのでそれぞれどういう仕様にするか考えていきました。

競技者には外部サービスの仕様は完全にブラックボックスなので外部サービスAPIの仕様を見れば、APIの仕様が分かるようにしました。今回は外部サービスという設定だったので、競技者にバイナリも渡さず、開発用のAPIサーバーを1台ずつ提供しました。そこで一連の動作確認はできるようにしました。

payment serviceについては以下の仕様にしました。

  • クレジットカード非通過化を実現するためにCORS APIを提供して、加盟店IDとカード番号を紐付けたトークンを発行する
  • アプリケーションからはトークンのみを扱うようにする
  • 決済に失敗するカード番号も存在するが、アプリケーションからはトークンしか渡していないので、実際に決済しないと失敗するか分からない

CORSは特に学生だと馴染みがないことが想定されましたが、ここはスコアに関係するところではないし、CORSの必要性を勉強するいい教材になりそうだと思ったので実装しました。私が何度もCORS APIを実装した経験があり、自分なら一瞬で実装できるというのも大きかったです。

今回カード番号は16進数8桁という実際のカード番号とは全く違う形式にしました。これは本物のカード番号と同じ仕様にすると、本物のカード番号を入力する競技者が現れかねないので、実際のものとは全く違う仕様にしました。

ちなみにCORSのAPIは証明書が不正な場合、無視する方法がない(少なくとも自分はやり方を知らない)ので、今回は正当な証明書を用意しました。

shipment serviceについては以下の仕様にしました。

  • 配送会社が直接住所を扱うことで、ユーザー同士は住所を教えることなく利用できる
  • 配送状況を返すAPIがある
  • アプリケーションを使う出品者・購入者だけでなく、配送会社の人間がURLを扱う必要があるので、配送会社の端末で撮影することを想定したQRコードを扱う

shipment serviceのQRコードを使う仕様は必要性がわかりにくく、実際に勘違いしている人もいたようです。根本的な問題として、どこに送られるかを出品者が分からない状態で、配送会社の人はどこからどこに配送するかを知らなければなりません。そうなるとQRコードから受け取ったIDからどこからどこに配送すればいいか分かり、配送会社は伝票を印刷することができるという仕様を想定していました。配送会社の人にはアプリケーションのユーザーではないが、IDとトークンを含んだURLを共有する必要があるという特殊な要件からQRコードが必要でした。なんかそれっぽいからQRコードを表示していたわけではありません。

QRコードにURLが含まれていればAndroidもiOSも標準のカメラとブラウザで開けるということは実機で確認していたので、競技者全員扱えるだろうということも確認していました。ちなみにこれは弊社が自分の持っていないOSの端末を支給してくれる制度で私がAndroidとiOSの2台を持っていたので検証できました。

APIの仕様についてちゃんと理解していれば、配送が完了した後はstatusが変わることはないという結論を導けるようになっていました。今回の問題ではアプリケーション仕様書という形で使い方ガイドをISUCON史上初用意しましたが、このQRコードの仕様などについて理解してもらうために用意したと言っても過言ではありません。

ベンチマーカー

ISUCONのベンチマーカーについてどこまで知られているか分かりませんが、以下の特徴があります。

  • 複数のシナリオに沿ったリクエストとレスポンスの検証を全て並行に行う必要がある
  • 並行にアプリケーションに対して負荷をかけていく必要がある
  • アプリケーションがどういうレスポンスを返すべきか管理している必要がある
  • チートができる仕様だとチートしたチームが上位になってしまい、ゲーム性が薄れる
  • 遅い初期実装でもちゃんとスコアを出せて、かつ早いアプリケーションにも負けないようにする必要がある

この特徴のためベンチマーカーはISUCON運営の中で最も実装が大変なアプリケーションです。

以下、自分が実装したことのいくつかを説明していきます。

以前の反省から以下の方針を決めていました。

  • ペラ1のファイルから拡張していく
  • フレームワークっぽい仕組みを作らない

ISUCONのベンチマーカーは例外のオンパレードです。フレームワークっぽい仕組みを用意しても無駄になるか、異常に複雑になるかのどちらかです。今回は1から作ることで最初のコストは高かったですが、メンテナンス性の高いベンチマーカーを作ることができたと思います。それによってベンチマーカーは最終的に以下のpackage構成になりました。

asset
- 初期データなどの管理。リクエスト生成やレスポンス検証に使われる
fails
- morikuni/failureを前提にして競技者に見せるエラーメッセージを管理する
scenario
- シナリオを管理。触る必要がある変数も多い上に、とにかく複雑になるが許容
server
- 外部サービスを管理
session
- アプリケーションにリクエストを送って、レスポンスの簡単な検証やJSONの解釈などを行う

今回のベンチマーカーはスコアが売り上げになるという特性からシナリオは出品をしてから様々なエンドポイントにリクエストを送り、そこから取引完了にするという特殊なシナリオになっています。この状況がなくてもシナリオはやることと準備することが多く、複雑になることは避けられません。ここは実装スピードなどを考えるとpackageを分けずに実装するのが最適と判断しました。

session は全てのエンドポイントについて実装します。コピペが多くなりますが、一部特殊な実装があるので、ここはコードが分かれていることを優先します。すべてcontextを渡せるようにしてcontextで外から殺せるようにしておきます。これでベンチマーカーから任意のタイミングですべてのリクエストを中断することができます。ちなみにアプリケーション側で最後に499が発生するのはこのためです。

今回は外部サービスの管理もあったので以下の仕様もありました。

  • 外部サービスのレイテンシを管理する必要がある
  • IPアドレス制限をする必要がある

これについてはMiddlewareを参考にレイテンシとIPアドレス制限を実装しました。

またベンチマーカーと合体したことで、ベンチマーカーは外部サービスの内部状態の変更をHTTPリクエストではなく、直接内部のデータ変更を行って実現しています。

他にもpayment service側に様々な情報を持たせることで、payment serviceが直接その決済が正当か検証できるようにしていました。最後に付き合わせるtransaction_evidencesのデータもpayment serviceから生成していました。

エラーの取り扱いについては以下の事情がありました。

  • 運営は生のエラーを見たいが、競技者にはこちらで用意したメッセージを見せたい
  • すべてのエラーにmessageを付与してわかりやすいメッセージを競技者に提供したい
  • criticalなエラーなどエラーにも深刻度によっていくつか種類が必要

これらを解決するために全てのエラーはmorikuni/failureを前提にしてエラーコードとエラーメッセージを生成します。

  • 全てのエラーは標準エラー出力に出す
  • エラーが発生した場所で競技者向けの適切なメッセージを failure.Messagef などで付与する
  • エラーの種類を failure.StringCode で表現する

これのおかげでかなり自然にエラーとエラーメッセージを取り扱えることができました。ISUCONのベンチマーカーのエラー管理にfailureを使うことはデファクトスタンダードになると思います。一部の参加者の方からエラーメッセージがわかりやすかったという意見をもらいましたが、これはfailureで全てのエラーに独自のメッセージを付与できたことが大きいです。

ベンチマーカーは問題が見つかったら極力早く終了できるようにいくつかのフェーズがあります。

initialize
- 初期化リクエストを送る
verify
- 初期チェック:正しく動いている確認する
validation
- メイン処理:checkとloadの大きく2つの処理を行い、動作確認するgoroutineと負荷をかけるgoroutineが起動する
final check
- 最終チェック:ベンチマーカー側の記録とアプリケーションの/reportsの記録を付き合わせて最終的なスコアを算出する

いずれの処理も失敗したらそこでベンチマーカーは終了します。予選では1台のサーバーから負荷をかける構成で40台用意していました。参加チーム数は40より多いので、問題があるアプリケーションをデプロイしているチームはさっさと終了することでリソースを使い果たさないようにしています。今回は証明書を検証していたのであまり関係ないですが、他サービスへの攻撃に利用されるのを防ぐ意味もあります。昔は完全に性善説で運営されていたISUCONですが、そういう運営で成立するISUCONではなくなってきたという経緯もあります。

この辺りは cmd/bench/main.go にコメントも色々書いたので参考になると思います。

今回はアプリケーションが正当な証明書を持っており、HTTPSで通信する構成になっていました。そして全競技者が同じ証明書とホスト名のアプリケーションを提供していました。もちろんDNSは登録されていないですし、毎回hostsを書き換えるのも大変なので、ベンチマーカーに以下の機能が必要でした。

  • 指定したHostヘッダーを渡す
  • 指定したHostと同じものをServerNameとして渡して証明書を検証する

これはGoの場合は req.Host を書き換えるのと、 http.TransportTLSClientConfigServerName を渡すことで解決できます。

しかしこれだとGoの場合はHTTP/2を使えなくなるという問題があります。これを回避するには以下の方法があります。

  • net/http のコードをコピペしてパッチを当てる
  • http2.ConfigureTransport を呼び出す
  • Go 1.13から入った ForceAttemptHTTP2 を使用する

1番目の方法はISUCON6本選で自分が実際に使った方法です。2番目の方法は以前にエントリーを書いたので参考にしてください。

今回は3番目の方法を使用しました。自分がやりたいことを一番正攻法で解決できる方法だと判断したからです。しかしGo 1.13がリリースされたのはISUCON9予選の直前で、開発時にはまだリリースされていませんでした。参考実装移植をお願いしている人にまだリリースされていないGo 1.13のインストールをお願いするのは難しかったので、今回はbuild tagsを使ってGo 1.13の時だけ有効になるようにしました。今ではもうリリースされているので不要な仕組みだと思います。

注意すべきは http.Transport を都度生成しないとHTTP/2にしたときにコネクションがまとめられてしまう点です。普段は便利ですが、ISUCONのベンチマーカーはアプリケーションに対して負荷をかける必要があるので、都度コネクションを張りたいはずです。http.Transport を都度生成するのを忘れないように実装しました。

ちなみに今回のベンチマーカーは研修や練習などでも使いやすいようにHTTPでも問題なく動くようにしてあります。大会中は正当なTLSの証明書を用意しましたが、無理にTLSの証明書を用意する必要はありません。

過去のISUCONのベンチマーカーにはworkloadという仕組みで負荷を調整する仕組みがありました。これはベンチマーカーを作る側からするとやりやすい仕組みですが、競技者側からすれば何を意味するかわかりにくいという欠点がありました。

そこでISUCON6本選で初めて採用されたのが、自動でリクエスト数が増えていき、タイムアウトが出たら減らす仕組みです。この仕組みはその後のISUCONではデファクトスタンダードになり、歴代採用されていました。

しかしこの仕組みはタイムアウトエラーが発生するまでリクエスト数を増やし続けるので、タイムアウトエラーを避けることはできません。また閾値を超えるかどうかで負荷が大きく揺れるので、スコアが安定せず、再現性が低いスコアを出してしまいます。

特に今回のアプリケーションの場合、リクエスト数を増やした瞬間にタイムアウトエラーが頻発する現象があり、自動で負荷を増やしていく仕組みを用意することは困難でした。

そこで今回はキャンペーンに与える数値でgoroutine数を決定する仕組みにしました。これによってベンチマーカーよりもアプリケーションが早くなった場合に頭打ちになる問題は起こりますが、その代わりに安定したスコアを出すことができるようになります。

この辺りはISUCON8本選の内容を参考にしました。これについては経緯を含めて以下のエントリーに詳しく解説されているので是非参考にしてください。

大会中に説明されていませんでしたが、キャンペーンを有効にすると人気者出品と私が呼んでいるイベントが発生していました。

人気者出品は1人のユーザーが高額の出品をして、その商品を大量のユーザーが購入しようとします。それが成功すればサービスの信頼性が上がって商品単価が上昇するという機能でした。

この機能は以下の要件があります。

  • 複数のユーザーが購入に成功したらcritical error扱いにする
  • 全員が失敗したらapplication errorで減点
  • 一定数が失敗したら商品単価は上がらない

この辺りを全力で解決しているのがこのコードです。コメントも色々書いたので、並行プログラミングに興味があれば読んでみてください。

当初はQRコードの画像をデコードするライブラリを使って、URLを抽出してURLが正しいかどうかを検証しようとしていました。しかしライブラリが対応していない画像が大量にあることが途中で判明しました。

そこで初期実装で必要な情報を追加で返すようにして、QRコードは画像ファイルのmd5値を確認するようにしました。おそらくQRコードを実際にはデコードしていないことに気づいた競技者はいないのではないでしょうか。この辺りが簡単にできたのはベンチマーカーが外部サービスを自前で完全に持っていることが大きかったです。

最後に

他にもいくつかの仕組みがありますが、自分が中心に作ったものはこんな感じです。かなり苦労もありましたが、最終的にはいい形にできたので本当によかったと思います。私が中心に実装を行いましたが、様々な方の協力があって完成したもので、一緒にやってくれた方には本当に感謝の気持ちでいっぱいです。おかげでいい問題ができました。また集中して問題作成できるように配慮してくれた弊社や弊社の人たちにも感謝の気持ちでいっぱいです。

私をここまで育ててくれたISUCONというイベントに対して、感謝の気持ちしかありません。ISUCONがなければ、今の私はありません。そんなISUCONに対して、少しでも恩返しができればという気持ちで2回目の運営を引き受けました。この問題によって色々な知識を吸収してくれる人がいれば本望です。

これからもこの問題を練習や研修など様々な用途で使ってくれたらうれしいです。その際はブログなどで教えてくれたらうれしいです。よろしくお願いします。

Written by

将来の夢は隠居です

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store