ISUCON8 の予選問題を復習した
今年もISUCONには参加する予定で、チームメンバーを集めるところまでは進めていたのですが、日程が 別のイベント と重なってしまい、参加できませんでした。。。
ISUCONの問題は取り組んでいてワクワクしてくる良問が出ているので、ネタバレを食らう前に復習しようと思いました。
復習するまで参加記や講評などは読まないようにしていたのですが、Twitterを見ているだけでも情報が流れてきてしまったので、以下の情報は知ってしまっていたことを予め申し上げておきます。
- OSはCentOS 7
- 複数台構成
- HTTPサーバーはH2O
OSとネットワークの準備
この記事を書いている間にConoHaでISUCON8のイメージが公開されました。新しく利用される方はそちらを利用するのが賢明かと思います。
予選問題のリポジトリが公式に公開されていたので、それを利用しました。
GitHub - isucon/isucon8-qualify
サーバーはVagrantで構築してもよいのですが、なるべく本番と同じスコアを再現したかったのでVPSを借りました。本番でどこのサービスのサーバーが使われたのか分からないのでエスパーするしか無いのですが、今年はサーバーの提供がGMOインターネットだったので、ConoHaのメモリ1GBのコース を使いました。1GBのコースはvCPUが2つであるなど、スペック的にも README に書いてあるものと近いので、たぶん合っているんじゃないかと思います。
サーバーは競技用3台+ベンチマーク1台の合計4台を契約して、プライベートネットワークで接続しました。
プライベートネットワークを構築すると、以下の eth1
のようなインターフェースが生えます。
[isucon@150-95-191-226 ~]$ ip a 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 link/ether 02:01:96:5f:bf:e2 brd ff:ff:ff:ff:ff:ff inet 150.95.191.226/23 brd 150.95.191.255 scope global noprefixroute dynamic eth0 valid_lft 86327sec preferred_lft 86327sec inet6 2400:8500:1302:847:150:95:191:226/128 scope global noprefixroute dynamic valid_lft 7429sec preferred_lft 7129sec inet6 fe80::1:96ff:fe5f:bfe2/64 scope link noprefixroute valid_lft forever preferred_lft forever 3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 link/ether fa:16:3e:b7:d7:68 brd ff:ff:ff:ff:ff:ff inet6 fe80::3d00:ea4a:4342:9f10/64 scope link noprefixroute valid_lft forever preferred_lft forever [isucon@150-95-191-226 ~]$
よく見るとわかるように、インターフェースは認識されているのですが、IPアドレスは振られていません。IPアドレスの振り方はConoHaの公式サイトに書いてあるのでそれに従いました。RHEL7からNetworkManagerというものを使うのが推奨されているのですが、凝った設定をするわけではないのでConoHa公式サイトに載っている方法で進めました。
プライベートネットワークのセグメントを 192.168.0.0/27 に設定したので、設定ファイルはこんな感じでしょうか。
$ cat <<EOF | sudo tee /etc/sysconfig/network-scripts/ifcfg-eth1 TYPE="Ethernet" BOOTPROTO="none" IPADDR="192.168.0.X" NETMASK="255.255.255.224" DEVICE="eth1" ONBOOT="yes" EOF $ sudo service network restart
全台に設定するとちゃんと繋がりました。親切なマニュアルが用意されていてよいですね。
[isucon@isucon8-1 ~]$ ping -c 1 192.168.0.2 PING 192.168.0.2 (192.168.0.2) 56(84) bytes of data. 64 bytes from 192.168.0.2: icmp_seq=1 ttl=64 time=0.649 ms --- 192.168.0.2 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.649/0.649/0.649/0.000 ms [isucon@isucon8-1 ~]$ ping -c 1 192.168.0.3 PING 192.168.0.3 (192.168.0.3) 56(84) bytes of data. 64 bytes from 192.168.0.3: icmp_seq=1 ttl=64 time=3.53 ms --- 192.168.0.3 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 3.537/3.537/3.537/0.000 ms [isucon@isucon8-1 ~]$ ping -c 1 192.168.0.4 PING 192.168.0.4 (192.168.0.4) 56(84) bytes of data. 64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=2.98 ms --- 192.168.0.4 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 2.981/2.981/2.981/0.000 ms [isucon@isucon8-1 ~]$
ちなみに、iperfで帯域を測ったところ730Mbpsほど出ていました。プライベートネットワークは1Gbpsとのことなので、こんなものでしょう。
プロビジョニング
初期実装のプロビジョニングをするためのAnsibleスクリプトが用意されているのですが、使い方に関して誤った解釈をしてしまい、余計な時間がかかってしまいました。
isucon8-qualify/provisioning at master · isucon/isucon8-qualify · GitHub
何を間違っていたかというと、Ansibleでプロビジョニングをする前に、isuconユーザーを作成してwheelグループに参加させるという手順を行ってしまっていたことです。isuconユーザーの作成などを行うタスクがprovisioning/roles/common/tasks/main.ym
の中で定義されているので、手動でユーザーを作成後にプロビジョニングしようとすると不整合が生じて失敗してしまいました。ユーザーの作成もAnsibleに任せるのが正しかったようです… 私は provisioning/roles/common/tasks/main.ym
を編集してユーザー作成に関するタスクを削除することで乗り切りました。
プロビジョニングをする際にお勧めしたいのは、使用する予定がない言語のインストールをスキップすることです。私は Python だけ使う予定だったので、Go, NodeJS, Perl, PHP, Ruby のインストールをスキップするように設定を修正しました。このとき webapp-*.yml
から isntall_*
を削除するだけでは不十分で、これでは prepare_webapp
で npm install
などの処理を行おうとしたときに npm, carton, composer, bundler などを使った依存ライブラリのインストールに失敗してしまいます。これに関してもファイルの編集で対応しました。
diff --git a/provisioning/roles/prepare_webapp/tasks/main.yml b/provisioning/roles/prepare_webapp/tasks/main.yml index fde4e67..2481266 100644 --- a/provisioning/roles/prepare_webapp/tasks/main.yml +++ b/provisioning/roles/prepare_webapp/tasks/main.yml @@ -79,38 +79,6 @@ src: torb.ruby.service dest: /etc/systemd/system/torb.ruby.service -- name: Install for torb.nodejs - become: yes - become_user: isucon - args: - chdir: /home/isucon/torb/webapp/nodejs - shell: | - bash -lc "/home/isucon/local/node/bin/npm install" - -- name: Install for torb.go - become: yes - become_user: isucon - args: - chdir: /home/isucon/torb/webapp/go - shell: | - bash -lc "make clean && make deps && make" - -- name: Install for torb.perl - become: yes - become_user: isucon - args: - chdir: /home/isucon/torb/webapp/perl - shell: | - bash -lc "carton install" - -- name: Install for torb.php - become: yes - become_user: isucon - args: - chdir: /home/isucon/torb/webapp/php - shell: | - bash -lc "composer install" - - name: Install for torb.python become: yes become_user: isucon @@ -118,11 +86,3 @@ chdir: /home/isucon/torb/webapp/python shell: | bash -lc "sh setup.sh" - -- name: Install for torb.ruby - become: yes - become_user: isucon - args: - chdir: /home/isucon/torb/webapp/ruby - shell: | - bash -lc "bundle install --path=.bundle" diff --git a/provisioning/webapp1.yml b/provisioning/webapp1.yml index cfca84d..b85205f 100644 --- a/provisioning/webapp1.yml +++ b/provisioning/webapp1.yml @@ -4,12 +4,7 @@ - common - install_h2o - install_mariadb - - install_go - - install_nodejs - - install_perl - - install_php - install_python - - install_ruby - deploy_webapp - prepare_webapp - initialize_db diff --git a/provisioning/webapp2.yml b/provisioning/webapp2.yml index 381717a..8d5fa41 100644 --- a/provisioning/webapp2.yml +++ b/provisioning/webapp2.yml @@ -4,12 +4,7 @@ - common - install_h2o - install_mariadb - - install_go - - install_nodejs - - install_perl - - install_php - install_python - - install_ruby - deploy_webapp - prepare_webapp - initialize_db diff --git a/provisioning/webapp3.yml b/provisioning/webapp3.yml index 98dd7a0..770947a 100644 --- a/provisioning/webapp3.yml +++ b/provisioning/webapp3.yml @@ -4,12 +4,7 @@ - common - install_h2o - install_mariadb - - install_go - - install_nodejs - - install_perl - - install_php - install_python - - install_ruby - deploy_webapp - prepare_webapp - initialize_db
競技開始
3台のサーバーを使うことができますが、最初から複数台構成でチューニングするのは大変なので、最初は1台構成でチューニングを行い、満足いくまでチューニングしてから複数台構成に変更する方針で進めました。
Pythonの初期実装でベンチマークを実行すると 567点 でした。
Initial commit: 567 · arosh/isucon8-qual@24e0aba · GitHub
ボトルネックが分からないとどうしようもないので、まずはHTTPサーバーのアクセスログにレスポンスタイムを記録する設定を加えました。Twitterで流れてきたネタバレによるとHTTPサーバーはH2Oとのことだったのですが、知らなかった体で一応確認しました。
[isucon@isucon8-1 python]$ sudo netstat -antup Active Internet connections (servers and established) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 0.0.0.0:3306 0.0.0.0:* LISTEN 5230/mysqld tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN 6210/python3 tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 5418/perl tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1152/sshd (略) [isucon@isucon8-1 python]$
80番ポートで待ち受けているのはPerlのスクリプトから動いている何かのようですね。ほかには8080番ポートでアプリケーションのPythonが待ち受けているのとMySQLも動いているのが確認できます。
プロセス番号を指定して ps コマンドで調べると以下のようになりました。
[isucon@isucon8-1 python]$ ps uwwp 5418 USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 5418 0.0 0.0 148548 0 ? Ss 16:31 0:00 perl -x /usr/share/h2o/start_server --pid-file=/var/run/h2o/h2o.pid --log-file=/var/log/h2o/error.log --port=0.0.0.0:80 -- /usr/sbin/h2o -c /etc/h2o/h2o.conf [isucon@isucon8-1 python]$
これが噂のH2Oですか! ご丁寧にも設定ファイルの場所まで表示されています。
設定ファイルの内容は以下のような状態でした。
user: isucon access-log: /var/log/h2o/access.log error-log: /var/log/h2o/error.log pid-file: /var/run/h2o/h2o.pid hosts: "localhost:80": listen: port: 80 host: 0.0.0.0 paths: "/favicon.ico": file.file: /home/isucon/torb/webapp/static/favicon.ico "/css": file.dir: /home/isucon/torb/webapp/static/css "/img": file.dir: /home/isucon/torb/webapp/static/img "/js": file.dir: /home/isucon/torb/webapp/static/js "/": proxy.reverse.url: http://127.0.0.1:8080/ proxy.preserve-host: ON
H2Oの設定ファイルは初めて見たのですが、すでに静的ファイル配信の設定も行われているようですね。例年のISUCONでは、最初に取り除くボトルネックは静的ファイルの配信のことが多いですが、今回はその部分はスキップしてもよさそうです。
続いてHTTPエンドポイントごとの処理時間を調べるため、アクセスログ解析ツールに必要な設定をH2Oの設定に組み入れます。アクセスログの解析にはいつも Kataribe を使っています。ISUCON8の予選当日の時点ではH2O向けの設定ファイルの書き方のサンプルがREADMEに載っていなかったようなのですが、予選後にH2O向けのサンプルが追加されたのでコピペするだけで使えます。
Merge pull request #17 from shiimaxx/h2o-example · matsuu/kataribe@63877a5 · GitHub
知らなかったふりゲームをするのも不毛な気がするので、このサンプルを使わせていただきました。初期実装のアクセスログを解析してみると以下のようになりました。
Top 20 Sort By Total Count Total Mean Min Max 2xx 3xx 4xx 5xx TotalBytes MinBytes MeanBytes MaxBytes Request 51 92.174 1.807328 0.003 9.132 49 0 2 0 2480 34 48 57 POST /api/actions/login HTTP/1.1 17 54.580 3.210590 0.583 7.678 17 0 0 0 253267 14513 14898 14923 GET / HTTP/1.1 7 32.911 4.701528 1.172 9.441 7 0 0 0 100730 11440 14390 22045 GET /admin/ HTTP/1.1 17 29.954 1.761998 0.284 5.039 17 0 0 0 1002838 58988 58990 59002 GET /api/events/10 HTTP/1.1 23 21.658 0.941646 0.002 3.823 17 0 6 0 1111 34 48 81 POST /admin/api/actions/login HTTP/1.1 5 16.534 3.306896 0.003 7.725 4 0 1 0 211 23 42 47 POST /api/users HTTP/1.1 10 13.411 1.341117 0.001 4.935 8 0 2 0 458 25 45 51 POST /api/events/11/actions/reserve HTTP/1.1 (略)
静的ファイルの配信が上位に来ていないので、HTTPサーバーの設定改善はあまり頑張らなくてもよさそうだということが分かりました。
次に dstat でシステムのリソースを調べてみると50%ほどIdleになっていることが分かりました。
[isucon@isucon8-1 webapp]$ dstat -tclmdrn ----system---- ----total-cpu-usage---- ---load-avg--- ------memory-usage----- -dsk/total- --io/total- -net/total- time |usr sys idl wai hiq siq| 1m 5m 15m | used buff cach free| read writ| read writ| recv send 13-10 17:33:16| 11 3 85 0 0 0| 0 0.05 0.10| 271M 20.0M 187M 513M| 747k 1234k|26.5 27.9 | 0 0 13-10 17:33:17| 0 0 100 0 0 0| 0 0.04 0.10| 271M 20.0M 187M 513M| 0 0 | 0 0 |1156B 90B 13-10 17:33:18| 0 0 100 0 0 0| 0 0.04 0.10| 271M 20.0M 187M 513M| 0 8192B| 0 1.00 | 390B 394B 13-10 17:33:19| 0 0 100 0 0 0| 0 0.04 0.10| 271M 20.0M 187M 513M| 0 0 | 0 0 | 592B 146B 13-10 17:33:20| 0 0 100 0 0 0| 0 0.04 0.10| 271M 20.0M 187M 513M| 0 0 | 0 0 | 800B 122B 13-10 17:33:21| 6 2 92 1 0 0| 0 0.04 0.10| 281M 20.0M 187M 504M| 64k 468k|2.00 44.0 | 853B 246B 13-10 17:33:22| 46 3 48 3 0 0| 0 0.04 0.10| 289M 20.2M 188M 494M| 560k 31M|20.0 290 | 352B 106B 13-10 17:33:23| 47 3 49 2 0 0| 0 0.04 0.10| 278M 20.2M 188M 506M| 448k 35M|14.0 147 |4747B 233k 13-10 17:33:24| 38 9 50 1 0 2| 0 0.04 0.10| 282M 20.2M 188M 502M| 192k 2876k|6.00 20.0 |5126B 179k 13-10 17:33:25| 40 7 50 0 0 3| 0 0.04 0.10| 282M 20.2M 188M 501M| 0 0 | 0 0 | 472B 146B 13-10 17:33:26| 40 8 50 0 0 3| 0 0.04 0.10| 282M 20.2M 188M 501M| 0 0 | 0 0 | 860B 154B 13-10 17:33:27| 39 9 50 0 0 3|0.08 0.06 0.11| 282M 20.2M 188M 501M| 0 0 | 0 0 | 712B 114B 13-10 17:33:28| 40 9 50 0 0 2|0.08 0.06 0.11| 282M 20.2M 188M 501M| 0 8192B| 0 2.00 | 546B 106B 13-10 17:33:29| 39 7 51 0 0 3|0.08 0.06 0.11| 282M 20.2M 188M 501M| 0 36k| 0 2.00 |1386B 22k 13-10 17:33:30| 37 11 50 0 0 3|0.08 0.06 0.11| 282M 20.2M 188M 502M| 32k 268k|1.00 21.0 |2273B 2081B 13-10 17:33:31| 38 9 51 0 0 2|0.08 0.06 0.11| 282M 20.2M 188M 502M| 0 8192B| 0 4.00 |6599B 33k 13-10 17:33:32| 39 9 51 0 0 3|0.16 0.08 0.11| 282M 20.2M 188M 502M| 0 12k| 0 4.00 |2616B 43k 13-10 17:33:33| 39 9 50 0 0 2|0.16 0.08 0.11| 282M 20.2M 188M 502M| 0 4096B| 0 2.00 | 592B 122B 13-10 17:33:34| 45 5 50 0 0 1|0.16 0.08 0.11| 282M 20.2M 188M 502M| 0 4096B| 0 2.00 | 806B 254B
この結果からアプリケーションのワーカーの数が適切でないことが示唆されました。そのため、ワーカーの数を増やしてベンチマークを再度とったところ 848点 になりました。
Increase python worker: 848 · arosh/isucon8-qual@4e632bd · GitHub
CPUのIdle時間が減ったところで、topコマンドでどのプロセスがCPUを多く使っているのか調べました。
5230 mysql 20 0 1565008 163696 3224 S 123.3 16.1 3:37.35 mysqld 7720 isucon 20 0 331044 28260 4736 R 39.5 2.8 0:18.44 gunicorn 7721 isucon 20 0 449372 146044 4752 R 34.6 14.4 0:14.55 gunicorn
MySQLですね。innodb_buffer_pool_size
の設定をしたり、スロークエリログの設定をしてボトルネックを見つける必要がありそうです。
CentOS系のMySQLの設定ファイルの場所が分からなかったので mysql --help で調べると以下のような行が含まれていました。
Default options are read from the following files in the given order: /etc/mysql/my.cnf /etc/my.cnf ~/.my.cnf
/etc/mysql/my.cnf が見つからなかったので /etc/my.cnf の中身を見ると /etc/my.cnf.d の中を include していることが分かりました。/etc/my.cnf.d/server.conf を見ると以下のような状態でした。
# # These groups are read by MariaDB server. # Use it for options that only the server (but not clients) should see # # See the examples of server my.cnf files in /usr/share/mysql/ # # this is read by the standalone daemon and embedded servers [server] # this is only for the mysqld standalone daemon [mysqld] # this is only for embedded server [embedded] # This group is only read by MariaDB-5.5 servers. # If you use the same .cnf file for MariaDB of different versions, # use this group for options that older servers don't understand [mysqld-5.5] # These two groups are only read by MariaDB servers, not by MySQL. # If you use the same .cnf file for MySQL and MariaDB, # you can put MariaDB-only options here [mariadb] [mariadb-5.5]
設定されていなくて実質空ファイルですね。さらにMySQLではなくMariaDBで、バージョンも5.5ということで古めでした。5.5だとMySQLとほとんど差分が無かったんでしたっけ?昔のことなので忘れました…
設定はスロークエリログを書き出す設定と、innodb_buffer_pool_size
, innodb_log_file_size
, innodb_flush_log_at_trx_commit = 0
などの設定を行いました(ISUCON以外では innodb_flush_log_at_trx_commit = 0
は絶対にダメです…)。最終的な状態は以下のファイルをご覧ください。
isucon8-qual/server.cnf at master · arosh/isucon8-qual · GitHub
やたらと遅い /api/actions/login
を見てみました。これの中身は以下のようなものでした。
def get_login_user(): (略) cur.execute("SELECT id, nickname FROM users WHERE id = %s", [user_id]) return cur.fetchone() @app.route('/api/actions/login', methods=['POST']) def post_login(): (略) cur.execute('SELECT * FROM users WHERE login_name = %s', [login_name]) user = cur.fetchone() cur.execute('SELECT SHA2(%s, 256) AS pass_hash', [password]) pass_hash = cur.fetchone() if not user or pass_hash['pass_hash'] != user['pass_hash']: return res_error("authentication_failed", 401) flask.session['user_id'] = user["id"] user = get_login_user() return flask.jsonify(user)
遅い原因として予想されるのは
users.login_name
にインデックスが付けられていない- MariaDBの中でSHA-256を計算するのが遅い
get_login_user()
の中のSQLにあるusers.id
にインデックスが付けられていない- JSONにシリアライズするのが遅い
などです。実際にはこの中で1と3はハズレで、既にインデックスが付けられていました。2のSHA-256の計算が遅いというのが有力かな?と思って以下のように修正しました。
def sha256(s): if isinstance(s, str): s = s.encode('UTF-8') m = hashlib.sha256() m.update(s) return m.hexdigest() def post_login(): # 略 pass_hash = {'pass_hash': sha256(password)} # 略
これには全く効果がありませんでした。結局のところ /api/actions/login
が遅いのは最後まで修正することができませんでした。
遅い関数をエスパーするのは不毛なので、Werkzeug の WSGI Application Profiler を導入しました。gprof2dot を使うと、プロファイル結果をコールグラフの形で時間のかかっている処理を関数単位で見つけることができます。
実際にプロファイルを取ってみると get_events()
と get_event()
が重いことが分かりました。get_events()
の初期実装は以下のようなものでした。
def get_events(filter=lambda e: True): (略) cur.execute("SELECT * FROM events ORDER BY id ASC") rows = cur.fetchall() event_ids = [row['id'] for row in rows if filter(row)] events = [] for event_id in event_ids: event = get_event(event_id) for sheet in event['sheets'].values(): del sheet['detail'] events.append(event) (略) return events
この実装の改善できるところとして
get_events()
で id 一覧を取得してから、それぞれの id についてget_event()
の中で詳細情報を取得している(つまりN+1)get_event()
でsheet['detail']
の中身をかなり頑張って作っているのに、get_events()
から呼ばれた時には捨てている
という点に気づきました。最小限の手間で行える改善を考えたところ、get_event()
の引数に need_detail
というオプション引数を足しておいて、need_detail=False
のときには sheet['detail']
の中身を作らないという方針を考えました。
実装を進める前に get_events()
で返されるデータがどのようなものなのか確認しておくと役に立ちそうだと思ったので、調べてみました。
[isucon@isucon8-1 webapp]$ curl 127.0.0.1/api/events | jq '.' % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 1251 100 1251 0 0 1133 0 0:00:01 0:00:01 --:--:-- 1133 [ { "id": 10, "title": "平成狐合戦バブル経済", "total": 1000, "remains": 0, "sheets": { "S": { "total": 50, "remains": 0, "price": 7000 }, "A": { "total": 150, "remains": 0, "price": 5000 }, "B": { "total": 300, "remains": 0, "price": 3000 }, "C": { "total": 500, "remains": 0, "price": 2000 } } }, (略)
要するに、各イベントについてランクごとの総座席数 (total) 、残り座席数 (remains)、価格 (price) を計算しているようです。total と remains に関しては、get_event()
の初期実装では
cur.execute("SELECT * FROM sheets ORDER BY `rank`, num") sheets = cur.fetchall() for sheet in sheets: (略)
のようにして、各座席についてループを回して、座席数を += 1
のようにして数えているのですが、SQLで GROUP BY を使って集計することができそうですね。
また price に関しては、イベントの基本価格 event['price']
と、座席のランクごとの価格 sheet['price']
の合計で計算されていて、それぞれをデータベースから取得する処理を行っています。ところが、座席のランクごとの価格 sheet['price']
に関しては、価格を変更する機能がアプリケーションに含まれていないので、データベースから取得するのを省いてアプリケーションに埋め込んでしまうことができそうです。
need_detail=False
の場合の処理を分岐して以下のように変更しました。
ranks = ["S", "A", "B", "C"] sheet_price = {'S': 5000, 'A': 3000, 'B': 1000, 'C': 0} for rank in ranks: event['sheets'][rank] = {'price': event['price'] + sheet_price[rank]} # ランクごとの総座席数を計算する cur.execute('SELECT `rank`, COUNT(*) AS total FROM sheets GROUP BY `rank`') totals = cur.fetchall() for total in totals: event['sheets'][total['rank']]['total'] = total['total'] event['sheets'][total['rank']]['remains'] = total['total'] # ランクごとの予約済みの座席数を計算する cur.execute('SELECT sheets.`rank`, COUNT(*) AS reserved FROM reservations INNER JOIN sheets ON reservations.sheet_id = sheets.id WHERE event_id = %s AND canceled_at IS NULL GROUP BY sheets.`rank`', [event['id']]) reserved = cur.fetchall() # 総座席数 - 予約済み座席数 = 残り座席数 for rank in reserved: event['sheets'][rank['rank']]['remains'] -= rank['reserved'] for rank in ranks: event['total'] += event['sheets'][rank]['total'] event['remains'] += event['sheets'][rank]['remains']
この変更でスコアが 1725点 と倍近くに上がりました。
Fix get_event: 1725 · arosh/isucon8-qual@b08569f · GitHub
get_events()
に関してはかなりマシになりましたが、プロファイルの結果 get_event()
がまだ遅いことが分かりました。need_detail=False
の場合は改善されたので、次は need_detail=True
の場合を改善する必要がありそうです。
get_event()
の need_detail=True
の場合の処理を抜粋すると以下のような箇所があります。
cur.execute("SELECT * FROM sheets ORDER BY `rank`, num") sheets = cur.fetchall() for sheet in sheets: (略) cur.execute( "SELECT * FROM reservations WHERE event_id = %s AND sheet_id = %s AND canceled_at IS NULL GROUP BY event_id, sheet_id HAVING reserved_at = MIN(reserved_at)", [event['id'], sheet['id']]) reservation = cur.fetchone() if reservation: if login_user_id and reservation['user_id'] == login_user_id: sheet['mine'] = True sheet['reserved'] = True sheet['reserved_at'] = int(reservation['reserved_at'].replace(tzinfo=timezone.utc).timestamp()) else: event['remains'] += 1 event['sheets'][sheet['rank']]['remains'] += 1 event['sheets'][sheet['rank']]['detail'].append(sheet) (略)
座席 (sheet) の一覧を取ってから、それぞれの座席の予約 (reservation) の詳細情報を取得しています。つまりN+1です。N+1は id 一覧を取ってくるときに JOIN 区で詳細情報をまとめて取ってくる方針で改善することが多いですが、いくつか懸念事項がありました。
- 座席 (sheet) に対応する予約 (reservation) は存在しない場合がある(
if reservation
のelse
句)。INNER JOIN ではなく LEFT OUTER JOIN を使わなければ sheet が欠損してしまう。 - 予約 (reservation) の詳細情報を取ってくるときのSQLに
HAVING reserved_at = MIN(reserved_at)
という謎の句がある。同じ座席について複数の予約を取ってしまった場合の処置のようだ。予約の処理のトランザクション処理が正しく行えていれば不要なはずなので、これは消そう。
以下のようなSQL文に変更することで改善することができました。
SELECT sheets.id AS id, sheets.`rank` AS `rank`, sheets.num AS num, sheets.price AS price, reservations.user_id AS user_id, reservations.reserved_at AS reserved_at FROM sheets LEFT OUTER JOIN reservations ON sheets.id = reservations.sheet_id AND reservations.event_id = %s AND reservations.canceled_at IS NULL ORDER BY sheets.`rank`, sheets.num
この改善によりスコアが 11111点 にまで上がりました。
SQLに詳しい方ならこんな間違いはしないと思うのですが、reservations.event_id = %s
と reservations.canceled_at IS NULL
を書く場所について間違った理解をしていて、時間を無駄にしてしまいました。今回の場合は、これらの条件は LEFT OUTER JOIN の ON 句の中に書くのが正しく、WHERE 句の中に書くと結果が違ってしまいます。
Fix N+1: 11111 · arosh/isucon8-qual@d7b4798 · GitHub
この段階でプロファイルを取ってみると get_users()
が遅いのが気になりました。よく見ると get_events()
と同じで get_event()
の結果の sheet['detail']
を使っていないことが分かったので need_detail=False
を付けました。これで 16482点 まで上がりました。
Improve get_users: 16482 · arosh/isucon8-qual@d9ba3f9 · GitHub
次に気になったのは top コマンドの結果です。used のカラムを見ていると、まれにメモリをたくさん使っている瞬間があることに気づきました。
time |usr sys idl wai hiq siq| 1m 5m 15m | used buff cach free| read writ| read writ| recv send 14-10 20:51:17| 0 0 100 0 0 0| 0 0.22 0.63| 591M 11.0M 319M 70.5M| 0 0 | 0 0 | 694B 66B 14-10 20:51:18| 1 0 99 0 0 0| 0 0.21 0.62| 591M 11.0M 319M 70.2M| 32k 56k|1.00 2.00 | 676B 370B 14-10 20:51:19| 2 0 99 0 0 0| 0 0.21 0.62| 591M 11.0M 319M 70.2M| 0 112k| 0 24.0 |1999B 12k 14-10 20:51:20| 0 0 100 0 0 0| 0 0.21 0.62| 591M 11.0M 319M 70.2M| 0 0 | 0 0 | 840B 11k 14-10 20:51:21| 9 0 91 0 0 0| 0 0.21 0.62| 591M 11.0M 319M 70.2M| 0 0 | 0 0 |1293B 376B 14-10 20:51:22| 36 18 46 0 0 0| 0 0.21 0.62| 593M 10.3M 322M 66.6M| 12k 174M|3.00 397 | 514B 54B 14-10 20:51:23| 39 15 46 0 0 0|0.16 0.24 0.63| 621M 6684k 106M 258M|4096B 117M|1.00 351 | 506B 114B 14-10 20:51:24| 48 3 50 0 0 0|0.16 0.24 0.63| 682M 6684k 106M 197M| 0 0 | 0 0 | 910B 458B 14-10 20:51:25| 47 3 50 0 0 0|0.16 0.24 0.63| 757M 6684k 106M 122M| 0 0 | 0 0 | 896B 66B 14-10 20:51:26| 48 2 50 0 0 0|0.16 0.24 0.63| 821M 6684k 106M 59.0M| 0 0 | 0 0 | 320B 130B 14-10 20:51:27| 49 2 50 0 0 0|0.16 0.24 0.63| 827M 5132k 96.7M 62.5M| 0 560k| 0 9.00 | 836B 444B 14-10 20:51:28| 17 3 80 0 0 0|0.23 0.26 0.63| 595M 5132k 96.7M 295M| 0 0 | 0 0 | 45k 8721k 14-10 20:51:29| 0 1 99 0 0 0|0.23 0.26 0.63| 567M 5132k 96.7M 323M| 0 0 | 0 0 | 15k 3509k 14-10 20:51:30| 2 1 97 1 0 1|0.23 0.26 0.63| 569M 5132k 96.7M 321M| 20k 0 |1.00 0 |2772B 3279B 14-10 20:51:31| 0 0 100 0 0 0|0.23 0.26 0.63| 569M 5132k 96.7M 321M| 0 0 | 0 0 | 728B 204B 14-10 20:51:32| 1 1 99 0 0 0|0.23 0.26 0.63| 571M 5132k 96.8M 319M| 0 0 | 0 0 |2538B 2725B 14-10 20:51:33| 1 1 99 0 0 0|0.21 0.25 0.63| 569M 5140k 96.8M 320M| 0 48k| 0 2.00 |1384B 798B
プロファイルの結果 get_admin_sales()
が1回のレスポンス時間が長いことが分かったのですが、よく見ると reservations を全件取得して CSV 形式で書き出していることが分かりました。CSV に書き出す処理は以下のようになっています。
def render_report_csv(reports): reports = sorted(reports, key=lambda x: x['sold_at']) keys = ["reservation_id", "event_id", "rank", "num", "price", "user_id", "sold_at", "canceled_at"] body = [] body.append(keys) for report in reports: body.append([report[key] for key in keys]) f = StringIO() writer = csv.writer(f) writer.writerows(body) res = flask.make_response() res.data = f.getvalue() res.headers['Content-Type'] = 'text/csv' res.headers['Content-Disposition'] = 'attachment; filename=report.csv' return res
StringIO にすべて書き出しているのは改善できそうですね。Flask の機能でストリーム的にデータを送ることができた記憶があったので調べてみると、ドキュメントに Streaming Contents という項目がありました。これを参考に render_report_csv()
を改善した結果が以下のものです。
def render_report_csv(reports): keys = ["reservation_id", "event_id", "rank", "num", "price", "user_id", "sold_at", "canceled_at"] def generate(): yield ','.join(keys) + '\n' for report in reports: yield ','.join([str(report[key]) for key in keys]) + '\n' headers = {} headers['Content-Type'] = 'text/csv' headers['Content-Disposition'] = 'attachment; filename=report.csv' return flask.Response(generate(), headers=headers)
この改善に加えて reservations
を全件取得する際に cur.fetchall()
するのではなく、逐次的に読み込むためのイテレータを render_report_csv()
の引数 reports として渡すように変更しました。これにより 17517点 になりました。思ったより改善しませんでしたね。。。
Stream: 17517 · arosh/isucon8-qual@b5afe34 · GitHub
次は、また get_event()
が遅いのが気になってきました。今度は need_detail=False
の場合の高速化が必要な気がします。need_detail=False
の場合の処理を抜粋すると、以下のようなものでした(再掲)。
ranks = ["S", "A", "B", "C"] sheet_price = {'S': 5000, 'A': 3000, 'B': 1000, 'C': 0} for rank in ranks: event['sheets'][rank] = {'price': event['price'] + sheet_price[rank]} # ランクごとの総座席数を計算する cur.execute('SELECT `rank`, COUNT(*) AS total FROM sheets GROUP BY `rank`') totals = cur.fetchall() for total in totals: event['sheets'][total['rank']]['total'] = total['total'] event['sheets'][total['rank']]['remains'] = total['total'] # ランクごとの予約済みの座席数を計算する cur.execute('SELECT sheets.`rank`, COUNT(*) AS reserved FROM reservations INNER JOIN sheets ON reservations.sheet_id = sheets.id WHERE event_id = %s AND canceled_at IS NULL GROUP BY sheets.`rank`', [event['id']]) reserved = cur.fetchall() # 総座席数 - 予約済み座席数 = 残り座席数 for rank in reserved: event['sheets'][rank['rank']]['remains'] -= rank['reserved'] for rank in ranks: event['total'] += event['sheets'][rank]['total'] event['remains'] += event['sheets'][rank]['remains']
よく考えると、総座席数はイベントごとに変わらないので、sheet_price
と同じようにアプリに埋め込んでおくことができます。埋め込むように変更することで 17475点 になりました。あまり変わりませんね。
Improve get_event: 17475 · arosh/isucon8-qual@10790dd · GitHub
mysqldumpslow の結果を見てみると
SELECT sheets.`rank`, COUNT(*) AS reserved FROM reservations INNER JOIN sheets ON reservations.sheet_id = sheets.id WHERE event_id = %s AND canceled_at IS NULL GROUP BY sheets.`rank`
が遅いことに気づきました。これは、あるイベントに関して座席のランクごとの予約の数を数えるSQLです。これは別のテーブルを作って結果をキャッシュしておくことができます。ただし、予約が行われた時にはインクリメントして、予約がキャンセルされた時にはデクリメントしてやるなど、大きな改修が必要です。
まず /initialize
の中で以下のようなテーブルを作成しました。カラムの型は既存のものをパクりました。
CREATE TABLE IF NOT EXISTS sheet_reserved ( id INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT, event_id INTEGER UNSIGNED NOT NULL, `rank` VARCHAR(128) NOT NULL, reserved INTEGER UNSIGNED NOT NULL, UNIQUE KEY event_id_rank_uniq (event_id, `rank`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
このテーブルに適切にキャッシュしておけば
SELECT `rank`, reserved FROM sheet_reserved WHERE event_id = %s
というSQLで前述のSQLを置き換えることができます。適切にキャッシュしておくために reservations テーブルを INSERT, UPDATE, DELETE している箇所を探すと、以下の部分に変更が必要だとわかりました。
- 初期データを投入している部分 (
get_initialize()
) で、初期データの reservations についての sheet_reserved を更新する - 予約を追加する部分 (
post_reserve()
) で sheet_reserved をインクリメントする - 予約をキャンセルする部分 (
delete_reserve()
) で sheet_reserved をデクリメントする - イベントを追加する部分 (
post_admin_events_api()
) で sheet_reserved に 0 をセットする
この変更によって 29107点 まで上がりました。かなり上がってうれしいですね。
Cache sheet_reserved: 29107 · arosh/isucon8-qual@765cfba · GitHub
ソースコードを眺めていると get_event()
を呼び出すときに need_detail=False
にしてしまって問題ない箇所がいくつかあったので変更しました。また、reservations の全件取得でメモリ爆食い容疑のある get_admin_sales()
について、オブジェクトの生成数を減らせる方法があったので変更しました。これにより 32865点 まで上がりました。
More need_detail=False: 32865 · arosh/isucon8-qual@38bb549 · GitHub
また get_event()
の改善に戻ります。今度は need_detail=True
の場合です。N+1を改善するために使った以下のSQLがmysqldumpslowで上位に来てしまっていました(再掲)。
SELECT sheets.id AS id, sheets.`rank` AS `rank`, sheets.num AS num, sheets.price AS price, reservations.user_id AS user_id, reservations.reserved_at AS reserved_at FROM sheets LEFT OUTER JOIN reservations ON sheets.id = reservations.sheet_id AND reservations.event_id = %s AND reservations.canceled_at IS NULL ORDER BY sheets.`rank`, sheets.num
LEFT OUTER JOINを使っているのは、予約が無い座席についても、座席番号と座席のランクの情報が必要なためでした。ところで、座席の数や座席のランクは変化することが無いのですが、LEFT OUTER JOINを使う必要があるのでしょうか? アプリにデータを埋め込んでおくことで reservations.sheet_id
から座席番号と座席のランクは計算できます。すると、予約がない座席についてのデータを取得する必要がなくなるので、LEFT OUTER JOINを使う必要がなくなります。SQL文は
SELECT sheet_id, user_id, reserved_at FROM reservations WHERE event_id = %s AND canceled_at IS NULL ORDER BY sheet_id
のようにシンプルになります。その代わり、
def convert(sheet_id): if sheet_id <= 50: return ('S', sheet_id) if sheet_id <= 200: return ('A', sheet_id - 50) if sheet_id <= 500: return ('B', sheet_id - 200) return ('C', sheet_id - 500)
のようにして sheet_id から座席のランクと座席番号を計算しなければならなくなったのと、予約が無い座席についても欠損を埋めてやる必要が出てきてしまいました。この変更は面倒ですが、負荷をデータベースからアプリに移すことができるようになったので、複数台構成に変更したときに大きな効果が見込めます。
この変更によって 35554点 になりました。
Improve get_event: 35554 · arosh/isucon8-qual@b736900 · GitHub
ここまでで、単体構成で改善できそうな内容が無くなってきたので、複数台構成に変更することにしました。h2oでロードバランシングする方法は知らなかったのですが、/etc/hosts
に複数台のホストのIPアドレスを指定すればよいようですね。
Proxy Directives - Configure - H2O - the optimized HTTP/2 server
netstat を見ていると TIME_WAIT で埋め尽くされていたのでカーネルパラメータを変更しました。パラメータの意味はよく分かっていないので、Fluentdのドキュメントに載っている値をコピペしました(ちゃんと勉強したい…)
Before Installing Fluentd | Fluentd
H2O、DB、アプリのワーカーの配置の構成はいろいろ試したのですが、H2O+DBのホストが1台、ワーカーを2つ起動したホストが2台の構成が最もスコアが安定しました。最高スコアは 52856点 です。ベンチマークのログは以下のようになりました。
[ info ] [isu8q-bench] 2018/10/16 01:19:57.549293 bench.go:378: ----- Request counts ----- [ info ] [isu8q-bench] 2018/10/16 01:19:57.549315 bench.go:381: POST|/api/actions/login 3022 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549327 bench.go:381: GET|/js/fetch.min.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549339 bench.go:381: GET|/js/jquery-3.3.1.slim.min.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549349 bench.go:381: GET|/css/layout.css 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549360 bench.go:381: GET|/css/admin.css 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549370 bench.go:381: GET|/js/app.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549380 bench.go:381: GET|/css/bootstrap.min.css 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549389 bench.go:381: GET|/js/bootstrap.bundle.min.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549399 bench.go:381: GET|/favicon.ico 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549408 bench.go:381: GET|/js/vue.min.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549418 bench.go:381: GET|/js/bootstrap-waitingfor.min.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549428 bench.go:381: GET|/js/admin.js 2324 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549441 bench.go:381: GET|/ 2268 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549450 bench.go:381: GET|/api/events/* 2237 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549460 bench.go:381: POST|/api/events/*/actions/reserve 2060 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549470 bench.go:381: DELETE|/api/events/*/sheets/*/*/reservation 621 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549481 bench.go:381: POST|/admin/api/actions/login 52 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549490 bench.go:381: GET|/admin/api/reports/events/*/sales 37 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549500 bench.go:381: GET|/api/users/* 29 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549517 bench.go:381: GET|/admin/ 28 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549528 bench.go:381: POST|/api/users 24 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549537 bench.go:381: GET|/admin/api/events/* 12 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549551 bench.go:381: POST|/admin/api/events/*/actions/edit 8 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549567 bench.go:381: POST|/admin/api/events 7 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549585 bench.go:381: POST|/api/actions/logout 6 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549602 bench.go:381: POST|/admin/api/actions/logout 6 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549617 bench.go:381: GET|/admin/api/reports/sales 2 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549630 bench.go:384: ----- Other counts ------ [ info ] [isu8q-bench] 2018/10/16 01:19:57.549647 bench.go:388: staticfile-200 25531 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549664 bench.go:388: load-level-up 9 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549680 bench.go:391: ------------------------- [ info ] [isu8q-bench] 2018/10/16 01:19:57.549826 bench.go:510: get 30177 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549844 bench.go:511: post 5185 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549855 bench.go:512: delete 621 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549886 bench.go:513: static 25531 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549897 bench.go:514: top 2268 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549908 bench.go:515: reserve 2060 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549919 bench.go:516: cancel 621 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549929 bench.go:517: get_event 2237 [ info ] [isu8q-bench] 2018/10/16 01:19:57.549939 bench.go:518: score 52856
Kataribeで集計したエンドポイントごとの時間は以下のようになりました。/api/actions/login
が一番遅いのが気になりますが、WSGI Application Profilerで何も出てこないところから考えて、ワーカーが空くのを待っている時間がそのまま計上されている可能性が高いと考えています。この部分だけ H2O+DB を動かしているホストに逃がしてみるという方法を検討しても良かったかもしれません。
Top 20 Sort By Total Count Total Mean Min Max 2xx 3xx 4xx 5xx TotalBytes MinBytes MeanBytes MaxBytes Request 17200 11945.441 0.694502 0.004 5.376 17142 0 58 0 849768 34 49 63 POST /api/actions/login HTTP/1.1 11020 9133.275 0.828791 0.004 5.374 10964 0 56 0 644742015 22 58506 59030 GET /api/events/* 11399 9030.855 0.792250 0.008 5.367 11399 0 0 0 183479922 14440 16096 17422 GET / HTTP/1.1 10940 8168.320 0.746647 0.002 5.264 10858 0 82 0 552419 25 50 51 POST /api/events/*/actions/reserve 3617 2615.991 0.723249 0.002 5.256 3506 0 111 0 2886 0 0 27 DELETE /api/events/*/sheets/*/*/reservation 268 277.131 1.034073 0.493 5.445 268 0 0 0 813519 113 3035 3334 GET /api/users/* 32 252.734 7.897946 5.105 13.156 32 0 0 0 397957946 12403378 12436185 12517753 GET /admin/api/reports/sales HTTP/1.1 324 150.378 0.464129 0.005 3.933 324 0 0 0 57680848 67 178027 826499 GET /admin/api/reports/events/*/sales 429 131.878 0.307407 0.005 5.230 344 0 85 0 21120 30 49 81 POST /admin/api/actions/login HTTP/1.1 327 94.422 0.288752 0.005 3.325 297 0 30 0 15765 23 48 59 POST /api/users HTTP/1.1 264 89.595 0.339373 0.002 3.112 264 0 0 0 4182721 11440 15843 24832 GET /admin/ HTTP/1.1 112 22.893 0.204401 0.002 1.868 56 0 56 0 786648 22 7023 14024 GET /admin/api/events/* 84 18.222 0.216934 0.002 1.503 28 0 56 0 394080 22 4691 14023 POST /admin/api/events/*/actions/edit 7 17.633 2.518975 2.454 2.557 7 0 0 0 0 0 0 0 GET /initialize HTTP/1.1 61 14.246 0.233536 0.002 3.541 33 0 28 0 463590 33 7599 14024 POST /admin/api/events HTTP/1.1 58 13.711 0.236404 0.001 5.226 29 0 29 0 957 0 16 33 POST /admin/api/actions/logout HTTP/1.1 60 11.122 0.185373 0.002 2.498 31 0 29 0 783 0 13 27 POST /api/actions/logout HTTP/1.1 11639 0.571 0.000049 0.000 0.013 11639 0 0 0 1640284270 140930 140930 140930 GET /css/bootstrap.min.css HTTP/1.1 11636 0.098 0.000008 0.000 0.013 11636 0 0 0 1005955472 86452 86452 86452 GET /js/vue.min.js HTTP/1.1 11639 0.000 0.000000 0.000 0.000 11639 0 0 0 12709788 1092 1092 1092 GET /favicon.ico HTTP/1.1
そのほかの取り組み
MySQL 8.0
MariaDB 5.5 から MySQL 8.0 にアップグレードすればオプティマイザが改善されているはずなのでスコアが上がるのではと思ってやってみましたが、逆にスコアが下がりました。設定のデフォルト値の変更や、クエリキャッシュの機能削除などいろいろな要因が考えられます。
Fail2ban
/var/log/secure
を確認しているとSSHでログインを試みてくる悪い人が沢山いることに気づきました。精神衛生上悪いので Fail2ban をインストールして、ログイン失敗を繰り返しているIPアドレスをブロックする設定を行ったのですが、Fail2ban を設定するとMariaDBへのアクセスにも何らかのチェックが行われるようで、スコアがかなり下がってしまいました。ISUCONではアンインストールしたほうが良いかもしれません。
tmux
複数台を同時に制御したいときは tmux の synchronized pane が便利でした。
複数のpaneを開いた後、C-b → ':' でコンソールっぽいものに入力できるようになるので
set-window-option synchronize-panes on
を実行すると複数paneに同じキー入力を送れるようになります。
追加で、複数paneの縦幅を均等にするにはtmuxのコンソールで以下のコマンドを実行します。
select-layout even-vertical
dstat
dstat のオプションは以下のものが気に入っています。
dstat -tcmsdn --tcp
お好みで -f をつけてもよいですが、横幅の大きいディスプレイでないと使いにくいでしょう。
まとめ
競技時間的には本番の倍くらいの時間をかけましたが 52856点 が出せたので良い練習になりました。来年は本番に出られるように頑張ります!