ISUCON9 に出て Ruby で予選通過した

ISUCON9 の予選にチーム「ソレイユ」で参加して 28 位でぎりぎり予選通過してきた。チームメンバーは osYoYu (@osyoyu) | Twitter霧矢あおい (@KOBA789) | Twitter で、なんだかんだ ISUCON5 の頃から参加している。ちなみに予選通過は初めて。

カッコ良いところは他の二人が書いてくれるとして、 自分は事前準備で Provisioning 周りとインスタ映えする Grafana ダッシュボード作成とアプリケーション周りのツール予習、 本番はドメイン知識貯めつつアプリケーションの調整をやっていたのでその辺を書く。 参加言語は Ruby で、最終得点は 10,090 点だった。

事前準備

参加 5 回目にしてチームで初めて準備と素振りというものをやった。勝因の 95% はそのおかげだと思う。

itamae 業

今回は周辺ツールのインストールに時間をかけないため、予め必要になりそうなものは全て itamae の cookbook を書いておいた。 後述するダッシュボードのための {node,mysql}_exporter とか、nginx, alp といった雑多なプロファイラなど、過去の参加記憶を掘り起こして書いてた。 あとはメンバーが適当なパッケージ名を issue に書いてくれるのでそれらのバイナリをポンするものも書いた。

github.com

事前に ubuntu(bionic)を使うと書いてあったので、その環境で動作するように Systemd Unit File も勝手に置くようにしてた。 素振り時に mtail の設定で正規表現のミスが見つかった意外に不具合もなく、本番も itamae apply してもらって完動してた気がする。

可視化業

チームメンバー全員可視化に関心があるおかげで、お家 Prometheus とお家 Grafana を持っており、 特に抵抗もないことから数値監視系は全て Grafana に寄せる話になった。

node_exporter を適当に入れて、 Node Exporter Full という既存のダッシュボードを持ってきて可視化したときは感動したと同時に情報量に惑わされてた。

grafana.com

最終的に過去必要になった Cpu, Memory, Network(receive, transmit), DiskIO だけに絞って PromQL を書いたら映えるダッシュボードが生まれた。

f:id:everysick:20190911002458p:plain
isucon monitor - 通称 isumon

ちなみに MySQL QPS は mysqld_exporter を、Nginx RPS は mtail で Nginx のアクセスログから HTTP Status Code ごとにサマリをカウントする設定を書いた。

正直 Nginx RPS が一番便利だった。403 Forbidden や 5XX 系の数がぼーっとしているだけで見えるので、そこから逆算して例えば出品者が自分の商品を買っていたり、売り切れ商品を買っていたりしそうだという目星をつけることができた。

本番

時系列に自信がそんなに無いので箇条書きで覚えていること書く。

  • 悠長にレギュレーション読んでいたら Alibaba ECS 完売してた
    • インスタンス生えるまで やれることがない スタンプを送りつつレギュレーション2周くらいした
  • 10:50 くらいで Grafana に node_exporter の状況がやってきて競技開始
  • koba789 や osyoyu はそれぞれかっこいいことやってた
    • alp や pt-query-digest の結果をベンチ後自動で GitHub issue に post してくれるやつとか(koba789)
    • rsync で 3 台一気に設定を同期するスクリプトの設定や mysql8 に移行するとか(osyoyu)
  • その間自分は
    • アプリケーション触ったり
    • APPLICATION_SPEC.mdEXTERNAL_SERVICE_SPEC.md を読み込んだり
    • エンドポイントや Table の schema 一覧引っ張ってきたり
    • /initialize 後のレコード数引っ張ってきたり
    • N+1 にコメント書いたり
    • index.html を nginx から返すようにしたり
  • 改善の最初は alp 等の結果を見つつユーザーをどうやって購買まで導くかチームで話していた
    • ベンチ直後の APPLICATION_SPEC.md にある isucari ステータス遷移表 ごとにレコード数を見て、どこの数値遷移で詰まっていてどうすれば購買フローをスムーズに流せるかなど
    • というのも過去の ISUCON と比べて初期の負荷が低くてリソースが余るなどしていたから
  • とりあえず売れる商品はあるのものの /buy が叩かれないことをどうにかしようとなる
  • 買うために必ず通る新着一覧やカテゴリごとの新着一覧で 403 が多い
    • コード上で 403 を返すのは「自分の商品を買う」「買えない(もう売れている)商品を買おうとしている」が原因だとわかる*1
    • レギュレーション曰くそこまで新着一覧の制約がきつくないのでそれらを非表示にするなどをした
    • 100イスコイン の椅子非表示とか値段順でソートとかやったけど全部怒られた。それはそう。
  • index.html を sinatra が返していたのを Nginx で返すようにした
  • この辺で 3 台構成になったので事前準備していた puma の設定を突っ込んだり調整したりした
    • 突っ込んだら Nginx が 502 を喋り始めたので SOMAXCONN や Backlog の設定もしてもらう(osyoyu)
    • 最終的な構成は server1 が LB と app, server2 が DB, server3 が app (without 画像配信) という感じ
  • categories をインメモリにしてたりした(koba789)
  • 403 問題がある程度解決したあたりから N+1 潰しが始まる
    • データ一般に強い氏(koba789)が殺めていくのを横で typo 指摘してた
  • N+1 が潰れたあたりで index の検討が始まる(koba789)
  • /buy が微妙に重いので rack-lineprof の結果見ながらなるほどって言ってる
  • server3 の Disk が調子悪い問題が出現する
    • server4 のセットアップが始まる(osyoyu)
    • 発覚から20分くらいでしれっとサービスインさせる(osyoyu)
  • transactions あたりの N+1 をとってる(koba789)
  • puma や campaign の調整してたら puma がスタックする問題が発生し始める(なぜ?)
    • 各サーバーで puma を restart して回る
  • スコア 9,210
  • 再起動試験(17:30 くらい)
  • 何故かスコア 1/3 になる
    • server2 で golang 実装が動いてたのを止めたら治る(なぜ?)
  • この時点で 8,530 なので予選通過ライン達してない
  • puma がスタックする(なぜ?)
    • 各サーバーで puma を restart して回る
  • スコア 9,510
  • ログ系と exporter 系全部切る
  • メモリ余ってるので雑な index どんどん貼る(koba789)
  • ベンチマーカーの cancelled 祭りが始まる
    • exporter を停止したことで isumon 上で経過が見れなくなり不安になる
    • 部屋内でボレロを流して治安悪くする
  • スコア 10,090
  • 競技終了

所感

予選は通過することができた。最後のスコアはガチャのように思えるが、再起動試験後のスコアも予選通過ラインに達していたのでそこまで運が良かったわけではないと思う。ログを切って本当に良かった。

問題の構成や点数配分、レギュレーションの匂わせ方まで、過去一で面白い ISUCON だった。運営の皆さんありがとうございます。本選も面白いの期待しています。

心残り

今回は外部 API のリクエスト並列化をやりそこねたので、それがあればスコアはまだまだ上がったと思う。 一応外部 API リクエストは rack-lineprof を使ってローカルからアクセスしたログを見た。そのときは複数回送っても気にするほど遅くなかったので気に留めてなかったんだけど、解説・講評*2見たら

2つ目の課題のとなる配送サービスAPIですが、APIのレスポンスにかかるレイテンシがベンチマーク走行中のみ遅延を入れて 0.8秒かかるようにしてあります。アクセスログの分析をしたチームは気づいたかと思いますが、取引中の商品が多くなればレスポンスがかなり遅くなります。

と書いてあった。うーん。気付かんよ。 ということでこういうのへの対策として stackprof などの使用を検討し始めている。

golang で出ていたチームは bcrypt で苦戦していたけど Ruby は気になるほどではなかった。むしろ支配的になってくるのは MySQL のロックだと思う。非 golang 勢の参加記いっぱい読みたいので待ってます。

チーム方針として git を使わないでやってきた(サーバー上に開発環境整えている)のだけれど、事前準備をしたことによって当日手が空いてしまって(同時編集できないがために)作業が詰まる現象が起きた。後半はアプリケーションコードがボトルネックになったためにもどかしい感じが残る。本選はファイル分割とかしていこういうのを避けていこうとチームで話している。

感情が無いエントリになってしまった。予選通過はちゃんと嬉しいです。本選も準備して頑張るぞ〜!


これは isumon を部屋の壁に投影していたやつ。見てると精神が安定する。

LaTeX のコンパイル環境を docker に閉じ込めて使う

VM を含めて普段から4箇所ほど環境を使いまわしていると LaTeX の環境をそれぞれで整えるのがかなり億劫である. 昨年の卒業論文で書き始める前に構築した環境が割とポータブルだったのでそれをまとめて書く.

求めていた環境は以下のような形で,割とシンプルに満たせたと思う.

  • ローカルに LaTeX およびそれに関係するパッケージは一切入れない
  • docker とプロジェクトがあればどこでもビルドできる
  • ローカルに LaTeX 環境を整えた場合とコンパイル等の速度に差は無い
  • (編集はターミナルで完結させたい = GUI使いたくない)

github.com

README にも書いてある通り,プロジェクトごと一度引っ張ってきて解凍するだけで良い. docker を使っているので(docker 以外に)インストールが必要なものはないし,パッケージで必要なのがあっても Dockerfile に書いてしまえば問題無い.

Dockerfile のベースイメージは alpine をベースに作ってあるもの*1を使っているのでディスクもそんなに食べない.

Dockerfile

FROM paperist/alpine-texlive-ja

RUN apk add --update ghostscript git

RUN tlmgr update --self

# You can install package using tlmgr
# e.g.) RUN tlmgr install ulem

日本語入力系の設定が記載している .latexmkrc を参考に*2してコマンド類をまとめることで,コンパイル時のスクリプトも単純にしている.

build.sh

docker build -t latex-template .
docker run --rm -it -v $PWD:/workdir latex-template:latest latexmk main.tex

.latexmkrc

#!/usr/bin/env perl
$latex            = 'platex -synctex=1 -halt-on-error';
$latex_silent     = 'platex -synctex=1 -halt-on-error -interaction=batchmode';
$bibtex           = 'pbibtex';
$dvipdf           = 'dvipdfmx %O -o %D %S';
$makeindex        = 'mendex %O -o %D %S';
$max_repeat       = 5;
$pdf_mode         = 3;

ipsj のテンプレートくらいの内容なら特に問題無く扱える.コンテナ起動速度もコンパイル時間に比べれば一瞬なので気にならないはず. ブログの内容は古い可能性があるので,適宜リポジトリを参照してほしい.

signal を http で受ける process wrapper 書いた

結局使わなかったので供養のためにブログ化しておく.

github.com

docker を始めとするコンテナ環境では,設定を適用するため signal を送ることで graceful reload をする仕組みは相性が悪いことがある.ホスト環境から動作中のコンテナに対してなんらかの働きかけが必要だからだ.なので,例えば systemd 環境下で動作しているアプリケーションをそのままコンテナ化することが難しかったりする. 特に,複数のアプリケーションが強調して動作する仕組みが必要である場合,side car コンテナなどでアプリケーションを分割すると途端に困難さが増す*1. アーキテクチャの刷新をするのが最も健全な手段であるが,そうもいかない場合に対処するため,signal のインターフェースを http に露出させるプロセスの wrapper を書いた.

mvp

signal を露出させたいプロセスの例として signal_echo.rb を用意

# signal_echo.rb

running = true

Signal.trap('INT') do |signo|
  puts 'Traped SIGINT'
  running = false
end

loop do
  break unless running
end

signal_echo.rb を http-signal-proxy から立ち上げる Dockerfile を用意

# Dockerfile

FROM ruby:2.5

RUN wget -q https://github.com/s4ichi/http-signal-proxy/releases/download/v0.1.0/http-signal-proxy_v0.1.0_linux_amd64.tar.gz && \
  tar -zxvf http-signal-proxy_v0.1.0_linux_amd64.tar.gz && \
  mv http-signal-proxy_v0.1.0_linux_amd64/http-signal-proxy /usr/local/bin/http-signal-proxy && \
  chmod +x /usr/local/bin/http-signal-proxy

COPY ./signal_echo.rb /signal_echo.rb
CMD ["http-signal-proxy", "--port=8080", "--command=ruby signal_echo.rb"]

実行

$ docker run -d --rm -p 8080:8080 -t http-signal-proxy-test:latest 
81f3d8507baa9c2a945c20d920824b6ca18f3be4ef04c7b853ac9b33c0480356

$ docker ps
CONTAINER ID        IMAGE                           COMMAND                  CREATED             STATUS              PORTS                    NAMES
81f3d8507baa        http-signal-proxy-test:latest   "http-signal-proxy -…"   4 seconds ago       Up 3 seconds        0.0.0.0:8080->8080/tcp   jolly_spence

$ curl http://localhost:8080/http-signal/sigint
Successed to proxy sigint to destination command%             

$ docker ps                                    
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

実装は entrykit とか supervisord を参考にした.entrykit はなかなか厳しそうなコードがそこそこあることがわかった.

push or pull

コンテナ外部から reload したいとき(push 型)はこういう手段がいいのかなぁと思って書いた.結局使わなかったというのは,コンテナ内部で reload が必要かどうかを polling すれば(pull 型)十分であることがわかったからだ.push 型はいつか必要になるのかな….