2023/12/11

一人でひっそりとISUCON13に参加していました (5万点でFailしました)

さて今年も残すところあと3週間。11月末にISUCON13がありまして、その直後からシンガポールに5泊6日で遊びにいってた関係でISUCONのブログを書くのがすっかり遅くなってしまいました。今年のチームメイトは… いません! ISUCON9以来の一人参加です。チーム名はinit Sでした。昔からLinuxをこねくり回して壊していた人にはおなじみのシングルユーザーモードに入るコマンドから取った名前です。acidlemon is entering single user mode. ということです。

ISUCON9のときは「いやー一人でやるのは明らかに手が足りなすぎてしんどすぎる。もう一人ではやらない」って思ってた気がするんですが(実際点数も全然伸びた記憶がない)、人は喉元過ぎれば熱さを忘れる生き物ということでまたもや一人で出てしまいました。

結果から言うと、最終スコアは51172ということで上位30位ラインのボーダー付近だったのですが、最後のこのスコアを出すためにいれたtagsのキャッシュがちょっとミスっており再起動後の試験(目視確認)でNGになる感じで失格という感じでした。まぁ、目視確認やってもらえるレベルのスコアが出てたという点ではそれなりに健闘できたかなというところです。

はじまるまで

一人で出るとなにが大変かというと、とにかくチームメイトに組長がいないというのに尽きます。組長いればアカウントつくったりhosts設定したりデプロイ設定したり細かいところキャッシュしたり全部やってもらえる(もちろん自分でやるよりも手が早い)のでデカイボトルネックに集中できるんだよな~ というところなんですが、今回はいつの間にかアドバイザーで運営側に入ってたようなのでしかたない。まこぴーさんもそーだいさんと組んでるし、ということで、初動なにするかとか組長が普段やってくれていることを自分で出来るようにする訓練だけやっておくことにしました。

さくらのクラウドクーポンを作ってISUCON11予選の環境をつくり、ちゃんとインスタンス3台+ベンチ1台を用意して、そこから初動の訓練をちょろっとだけ数日前からやりました。

  • とりあえず手元でSSH鍵つくって送り込んで相互SSHできるようにする
  • apt install で色々入れる(dstat, netdata, percona-toolkit, net-toolsなど)
  • mysqlの設定を変えて別のホストからつながるようにする(bind-addressとrename userらへん)
  • mysqlのslowlogいれかたを復習する
  • 各種ログをローテーションする方法調べておく(nginx, mysqlなど、お作法がそれぞれ決まってる)
  • nginxのaccess.logをJSONで吐いてalpで集計する手順を確認しておく

アプリをどう高速かするかとかは練習してもしかたがないので、ひとまずお決まりの頻出インフラ系タスクを当日悩まないように復習したという感じですね。

あとはisucon13リポジトリを作ってお手元にcloneしておいて、当日を迎えました。

10:00-12:00 はじまったぞ

まずは初動…ということで、事前に訓練したサーバのセットアップなどなどから作業開始。上に書いたいろいろの他に

  • EBSを暖める sudo dd if=/dev/nvme0n1 of=/dev/null bs=1M
    • 今回40GBくらいのEBSだったので、約10分程度かかった
    • EBS暖めながらレギュレーション読んだり初期ベンチまわしたりしてた
  • どうせいつかやることになるはずなのでmysqlを3台目に分離
    • nginxで /initialize を1回isu03に向けたりしたが、後にinit.shみたらちゃんとホスト指定で初期化できることが分かったので戻した
  • サーバから必要そうなファイルを回収してgithub repoに格納 (acidlemon/isucon13)
    • 事前につくった各種スクリプトもrepoに投入してサーバに送り込めるようにする
  • init.sh では 10_schema.sql を流し込んでいなかったので、DROP TABLE IF EXISTSしつつスキーマを毎回initializeで流し込み直すようにする

などをやりつつ、初期のpt-query-digestとかalpを眺めてサクッと張れそうなINDEXを張りました。

12:00-13:00 アイコンどうにかする

最初から3台構成でデプロイできるようにする前提で下準備をしたのでまぁまぁ手間取りつつ、さっそく高速化していきます。

まずは、DBにアイコンをバイナリで突っ込んでいる postIconHandler がみるからにヤバそうなのでファイルに書くように対処していきます。

  • INSERTしてそのLastInsertIdを返しているけど、使われてなさそうなので固定で32とか適当に返す dd2916bf
  • ファイルにかく 0f3e4cb2
  • init.shに rm -rf ../img/user*.jpg を仕込んでちゃんと消す 02ebcbbd

ここで、sha256毎回出してるのも無駄じゃろということで、ファイル名にsha256を含めて保存する 171732e8 という小技を使いました。アイコン周りは1時間くらいかけて一旦どうにかなりました。

この手の画像がDBに入る問題はISUCONだと頻出問題な感じですが、今回優しいなあとおもったのは初期データの時点では icons テーブルがなんと空っぽということで、初期状態のDBから画像データを吸い出してstatic fileのディレクトリに置き直す…というちょっと面倒な作業が発生しなかったというところです。

アイコンが片付いたところで10000点を超えました。

13:00-14:00 ヤバそうなクエリをちょっとずつ削っていく

アイコン周りが片付いたところで、DBのヤバそうなクエリが時間使ってるクエリの上位に出てきたので、ちょっとずつそれに対処していきます。まずはstatsから。

  • 必要なINDEXをはる & usersから引いてJOINしているけどuser_idをCOUNT(*)すればいいのでusersをJOINしない b9681877

このstatsは明らかにループクエリなんですが、usersのJOINをやめるだけでちょっと負荷がさがるので、次にいきます。次に手をつけたのはダークモード関連です。13:30くらいからベンチマーカーが不調になりましたが、ひとまずこれくらいはサクッといけるはずということで動作確認なしで投入していきました。

  • themesテーブルはusersテーブルに結合して問題無さそうなので結合しちゃう 11257d2e
  • initial_users.sql に update users JOIN themes t on t.user_id = users.id SET users.dark_mode = t.dark_mode; を入れて初期データのthemesのデータをusersテーブルに反映 4337869d

14:00-15:30 ベンチが止まり、キューが混雑して試行錯誤に時間がかかる

おにぎりを食べながらレギュレーションを読んでいると、アイコンについてはGETするときにConditional GETしていて別のAPIで返しておいたicon_hashと変わってなければ304返すことが出来る というのがあるのでそれに対応しました dc1a2e88。で、これでOKかなとおもってnginxのアクセスログを確認しましたが304を返している形跡がなく、なんでやねんとおもったらダブルクォーテーションで囲まれているのでそれは取り除く必要がある、というやつでした 30354bcf 。ETag的なやつだから囲まれていた…ということですかね。

sqlxは普段ほとんど使わないのでstructに詰めるところを書くのが苦手なんですが、そうも言っていられないということでインラインにstructを定義してN+1クエリを解消してゴリッと1クエリで全部とるようにし、ランキングは元のロジックの集計するように修正しました 0ebf65ba。ハマることなくこれが一発でうごいたので結構自分で自分を褒めています。

14:30-16:00 DNSのことを考えはじめる

この辺まで来たところで、PowerDNSの負荷もだいぶ上がってきたので、いろいろ手を打ちました。

  • PowerDNSのプロセスは1台目のまま、mysqlだけ2台目に逃がす
    • /etc/powerdns/pdns.d/gmysql-host.conf に設定があるので gmysql-host=192.168.0.12 に書き換え
  • そもそもisudnsに大事なINDEXがないので追加
    • alter table records add index idx_records_name (name);

PowerDNSを1台目のままにしてあった理由はユーザ追加時に pdnsutil add-record というコマンドを打っているからなので、これもAPIアクセスに変更しました 323293e1。これで理論上はいい感じにプロセスを別サーバに分離できそうでしたが、pdnsのAPIがlocalhostからしかいけなさそうなのでbindするアドレス変更とかまで調べる時間が惜しいのでDBだけ分離したまま1台目にプロセスを置きました。

このほかにもPowerDNSの設定で cache-ttl=90query-cache-ttl=90 あたりをいれたり、レコード追加するときのTTLも0じゃなくしたりなどの指定を入れてました。この辺をやっていくと、

  • 1台目: nginx, isupipe, pdns でCPU100%
  • 2台目: mysql(isudns) でCPU80%
  • 3台目: mysql(isupipe) でCPU90%

という感じになり、WebAppとpdnsが同居している弊害が出始めます。

16:00-17:00 WebAppの負荷を下げる

明らかにまだDB側にヤバイクエリ飛んでいるのにWebApp側のCPUがボトルネックになっているのはマズい、ということで、色々小手先の変更を入れて負荷を下げていきます。

  • SetMaxDBConnを増やしてDB側によりたくさん接続する dd2a3466
    • 並列数上がって走行パターン変わるかなとおもったけどあんまり効果なし
  • goccy/go-jsonへ変更 1b0fdd5f
  • デフォルトの画像もちゃんとIf-None-Matchで304返す 99062865
  • isupipeのCPU負荷をちょっとでもヒマしている2台目に逃がすべくnginxのupstream設定をいろいろ入れる
    • この時点ではicon_hashをファイル名に入れていた関係で別ホストに逃がせないので、レスポンスにicon_hashを入れないエンドポイントだけ2台目に逃がす、というlocationをたくさん書きました nginx: isupipe.conf

ここまでやってもあまり有効打にならないということで、あえてDB側に負荷が寄るように画像のsha256のキャッシュメカニズムをファイル名からusersテーブルの追加カラムに変更 13c2b2b7というのをやったところ、WebApp側の負荷がガッと下がってまた3台目のisupipe MySQLにボトルネックが移りました。ここでスコアがゴリっと上がったので、「Globでファイル名リスティングするの重かったか~~」となりました。これで17000点くらいから30000点くらいまであがりました。

17:00-18:00 ラストスパートしていく

これで、DNSとアイコンがきちんと片付いた状態になったので、ここからまたisupipe DBに飛んでいるクエリをpt-query-digestで見ていって、足りないINDEXをポチポチ追加していきました。

で、ここまでやったところで17時半、38000点くらい。もうちょっと一押ししたいけどslowlog的にホットになっているもう1個のstatsのN+1クエリを潰している時間はない…ということで、この後に及んで更新がまったくない事に気付いたtagsをオンメモリキャッシュすることにしました。

なぜか何回かFailして、一部キャッシュするのを諦めるとなんとかベンチが通り、50000点に乗りました。今回は競技時間中の最終計測スコアが最終スコアになるというレギュレーションなので、netdataをアンインストールしたり各種ログを全部止めたりして2~3回回すと51172点が出たので、ここで作業終了。再起動して動かなくなる要素はないはずなので再起動試験はなし!で18時を迎えました。

で、結果発表のあとにインスタンスのログをみると、なんか計測してそうな時間に502エラーが出ている…。panicしているところのログをみていくと、「EC2が再起動されたあとに /initialize を呼ばずにtagsを読むとキャッシュに何ものってないのでOut Of Boundsなエラーがでる」というバグを最後に仕込んでしまったことがわかり、上位チームに対する事後整合性チェックでFail扱いになってしまいました。

51,172 init S (再起動後のデータチェックで不整合があったため)

競技が終わった後に、感想戦でtagsはそもそもDBから読むまでもなくGoのファイルにsliceでハードコードしてもいけてしまう、ということがわかり、いやーこれはもったいないことしたな~~ と思いました。そこちゃんと出来ていればギリギリ30位以内に入れたスコアなので、だいぶ惜しかったという感じです。まぁこの辺レビューとかで担保できなかったのは、一人でやっていることのつらさですよね。

おわりに

Failで終わったわけですが、それにしても一人で特別賞ラインの5万点くらいいくのはまぁがんばったほうなんじゃないか…と思いました。ベンチマークが50分くらい止まったり、その後もキューイングの待たされ方にムラがあったりはしましたが、自分はそれによって大きく時間を無駄にしたりはしなかったのでその点はよかったなというところです。

あとはアレですね、アイコンのハッシュをファイル名に入れるっていうのはちょっとよくばりすぎましたね。最初からシュッとusersテーブルにいれておけばよかったです。その辺最初からusersにいれとけばstatsのN+1バラしはもうちょっと進んだはずなので、あと2~3万点くらいは上乗せできたのではないか…と思いました。そのほか色々思ったのは

  • singleflightをゴリゴリ打ち込んで優勝したこともある人間ですが、今回はsingleflightが有効な箇所はあんまりなかったなと思っています。もちろんキャッシュしたほうがスコア上がりそうな部分はありましたが、singleflightの旨味はキャッシュもそうですがめっちゃ長い処理がthundering herdしちゃうときに1本しか同時に走らないようにするという同時実行数制御だと思っているので、statsがそんなに高頻度に呼ばれない今回はそこまで旨味なかったかなと思います
  • 感想ブログ読んでくとDNSをzone-fileで処理する関係でregisterを直列化したので伸びなかった的なのがあり、なるほどなあと思いながら読みました。うちは参考実装のままPowerDNS + MySQLにしておいて、INDEX追加してDNSプロセスとDBプロセスを物理的に分離することでDNS関連でCPUを300%程度消費していたのと、register時のレコード追加もコマンド実行からAPI実行に変えたのでそこのスループットはよさそう。ベンチ1回でアップロードされた画像の番号が1900くらいまでは行ってたはずなので、registerがおそらく900回くらいは回ってたようです
  • 今回は(別に批判的なニュアンスではなく)スコアリングが不明瞭な感じでした。マニュアルにはいろいろ書いてあるけど、どのエンドポイントをどうすれば点数が直接的にあがる、みたいなのは全然わかりませんでした。tipされたときのレコードを見ていくと、5点、10点、100点みたいなレコードはあったので、特定のシナリオに従ってtipする量が増えるんだなくらいはわかりましたが、どこかを集中的に早くすることで点数をガガッと稼ぐみたいなのは難しかったな~と思います。結果的にそういう特定のエンドポイントをめちゃ早くするみたいなことをせずにボトルネックを上から潰していった自分のところはやってる手数の割にはスコア伸びてる方なんじゃないか、と思いました

ということで、今年のISUCONはおわり! それではまたお会いしましょう~

2023/06/07

AWS CLIでSSOログインするときのちょっとお役立ち情報

ごく稀に書くお役立ち情報シリーズです。

  • AWS CLIでSSOログインするときに開くブラウザを指定したい
  • それをsshのProxyCommandに仕込んでEC2のtcp/22ポートをフルオープンせずSSHしたい

というお話です。

最近うちの会社にもSSOできるIdPが導入されたのでAWSもSSOできるようにする流れが来ています。それに先立ち技術調査…というわけでもないですけど、せっかくだし自分の個人のAWSアカウントもSSO出来るようにするか〜ということで、個人のGoogle Workspace(無償版G Suiteで使ってたやつ)をIdPにしてAWS SSOを設定しました。ちなみにAWS SSOは最近名前が変わってAWS IAM Identity Centerになりましたが、この記事ではとりあえず略して(馴染みのある名前で)AWS SSOと呼ぶことにします。

さて、AWS SSOを設定してGoogleアカウントでログインできるようにしたのはいいとして、そもそも元々自分の個人のAWSアカウントとGoogle WorkspaceはSAML連携の設定をしていたので単純にAWS SSOでAWS Console入れるようにしただけだとむしろクリック数が増えてて旨味がありません。

じゃあなんでAWS SSOを設定したかというと、AWS CLIでSSOログインできるようになって、そうするとお手元にIAM Userのアクセスキーなどを持たなくてよくなるというところが主な狙いです。セキュリティの向上ですね。必要に応じて aws sso login したときに初めて有効期限つきのクレデンシャルがふってくるので、万が一なにかがどうにかなっちゃったとしても、クレデンシャルに有効期限がついていれば被害の軽減に役立ちます。

AWS CLIのSSO設定は、 aws configure sso とかをやればいいのでドキュメントを読みながら設定すればサクッといけます。一通り設定したあとは、SSOセッションの有効期限を調整して使い始めればOKです。私はあまり短すぎてもイライラするので、18時間に設定して aws sso loginしたらその日いっぱいは再ログインしなくても使えるみたいな感じにしています。

これで必要に応じて aws sso login すればブラウザが開いてIdPで認証をし、ブラウザでAllowをポチっとおせばクレデンシャルが降ってくる感じになりました。便利ですね。これでもうIAM Userのアクセスキーはいらん! ということで私はこれの設定をしたら速効でアクセスキー無効にしました。

AWS CLIのSSOログインのブラウザを指定したい

ところで、私の環境依存の話ではあるのですが、このフローにはちょっと不満があります。AWS CLIが開くブラウザはデフォルトブラウザで、デフォルトブラウザに複数のGoogleアカウントでログインしていると、毎回Googleアカウントを選択しないといけないんですよねぇ。クリック数が1回増えるし、手が滑ってIdP設定してないアカウントをクリックしてしまうと403エラーになるしでいいことがありません。起動するブラウザを指定できれば、1個しかGoogleアカウントを使ってないブラウザを起動してAllowを1回クリックするだけでいいようにできるのですが… というのが今日の本題です。

その辺ググってみると、AWS CLIのGithubリポジトリにそれっぽいissueがあったりもするのですが、GUIセッションがない端末用に --no-browser フラグを追加するのは対応されましたが自由にブラウザを指定するフラグなどは用意されませんでした。

そのissueには「WSLだと環境変数BROWSERを設定したら指定できるぜ」みたいな事が出てきたりして一体それはどういう仕組みなんだみたいな感じになったのでAWS CLIのソースをたくさん読んでみたところ、AWS CLIがブラウザを起動するのは最終的にPython3のwebbrowserモジュールに任せていることがわかりました。で、このドキュメントを読んでいくと「環境変数 BROWSER が存在する場合はデフォルトのブラウザリストに先立ちこっちを試す」みたいなことが書かれています。そういうことね!

環境変数で起動したいブラウザのパスを設定すればいいのは分かりましたが、わたしがブラウザ起動したかったのはmacOSなので、ちょっと一手間必要です。macOSのコマンドラインからGUIのアプリを起動するにはopenコマンドを使います。Safariを起動するなら open -a Safari とかになります。で、これをそのまま環境変数BROWSERに設定してもうまく行かないので、結論としてはシェルスクリプトにして、環境変数BROWSERにはそのシェルスクリプトへのパスを設定しましょう、という感じになります。

#!/bin/sh

open -a "Safari" $@

こんな感じのシンプルなシェルスクリプトを作って run_browser.sh とかのファイル名でどこかに保存しておき、+x パーミッションを付けておきます。これを環境変数BROWSERに設定してやればOKです。BROWSER=$HOME/run_browser.sh aws sso login を実行すれば、デフォルトブラウザではなく、ちゃんと run_browser.sh で設定したブラウザでIdPにアクセスしにいくはずです。

ssm-agent経由のSSHログインにaws sso loginを挟みたい

個人のAWSアカウントに立ってるEC2にsshするのをssm-agent経由で出来るようにして、EC2を0.0.0.0/0に対して22番ポートあけるのをそもそも辞めたいみたいなことを考えていたので、その辺も叶える~/.ssh/config をこんな感じで書いています。これは、AWS CLIのpowawaというプロファイルを利用し、SSOセッションが期限切れだったらaws sso loginする、そうじゃなければ普通に aws ssm start-session する、というようなコマンドラインです。

host my-ec2
    user XXXX
    hostname i-00XXXXXXXXXX
    ProxyCommand sh -c "(aws sts get-caller-identity --profile powawa || BROWSER=$HOME/run_browser.sh aws sso login --profile powawa) && aws ssm start-session target %h --document-name AWS-Start-SSHSession --parameters 'portNumber=%p' --profile powawa"

この設定を書いておいて ssh my-ec2 とやると、

  • AWS CLIのSSOセッションが生きてるか確認して
  • 生きてなかったらブラウザを開いて aws sso loginして
  • aws ssm start-session してssm-agentをつかってProxyCommandにポート転送する

という感じでSSH接続が確立できます。体感としてはsshコマンドを実行するとブラウザが立ち上がってAllowするとSSH接続が確立する、という感じになります。~/.ssh/confighostname%h に展開されるので、hostname にEC2のインスタンスIDを書いておくというのがこの設定のチャームポイントです。

うされもん @acidlemonについて

|'-')/ acidlemonです。鎌倉市在住で、鎌倉で働く普通のITエンジニアです。

30年弱住んだ北海道を離れ、鎌倉でまったりぽわぽわしています。

外部サイト情報

  • twitter
  • github
  • facebook
  • instagram
  • work on kayac