🧑‍💻

初参加のISUCON14でインフラ担当してみた

に公開

ISUCONに参加してきました

会社の先輩と3人チーム「ぽいふる」で参加してきました。
エンジニア歴1年半(内半年はテストしかやれていないので実質1年)でやれる気が全くしなかったのですが、良い機会だし面白そうだしどうせならと思い参加してきました。
[担当]
インフラ・アプリ担当:私
インフラ・マネジメント担当:ちょだまひろちょりさん (職場のマネージャー兼エンジニア)
インフラ以外担当:ほげほげ(本物)さん (マネージャーと仲が良いつよつよエンジニア)

やったこと

勉強会と情報収集

業務後にチームでの勉強会、個人でISUCONの情報収集や用意して頂いた環境で手を動かして試してみたりしてました。
勉強会では主に情報収集した内容の共有とかDBのボトルネックの探し方などを教わっていました。
情報収集の中で特に参考にしていたのが以下の記事です。

参考記事

https://qiita.com/ryuichiastrona/items/c75e1fb624343bd61515#isuconとは
ISUCONに向けた勉強方法や本番でのボトルネック改善方法、注意点などがかなり参考になりました。
私たちのチームではISUCONでのタスクやボトルネックの調査方法、やらかした時のリカバリ方法などをスプレッドシートにまとめていたので、こちらの方の記事のように先駆者の皆様の記事をとにかく探して、ISUCONに向けたナレッジを貯めていました。
(なお本番)

https://tech.fusic.co.jp/posts/tideways-xhprof-profiler/
tideways_xhprofの導入や使い方がまとめてある記事です。
PHPで参加することを決めていたのでPHPのプロファイラも入れたいよねという話が挙がり、過去のISUCON参加者やPHPのパフォーマンスチューニングで使われるプロファイラの情報を集めたところ、色々あるけど結局xhprofが良さそうだなーと感じxhprofを導入することにしました。
ところが構築したISUCON13の過去問環境へ導入しようとしたところ、yumやapt、dnfでインストールすると

Command 'yum' not found, did you mean:
  command 'gum' from snap gum (0.13.0)
  command 'sum' from deb coreutils (8.32-4.1ubuntu1.2)
...

のエラーが出てしまい導入の段階でコケてしまう事態に。
何か他の方法がないか探してみたところ、こちらの記事にある方法でtideways-xhprofを導入することに成功しました。めちゃくちゃ助かった…

他のプロファイラの候補として「Reli」というサービスのプロファイラツールがあり、こちらも導入できたのですが、使い方の記事が少なすぎて全然分からなかったためxhprofに切り替えました。プロファイラの導入だけでかなり時間をかけてしまっていたので…
https://www.infiniteloop.co.jp/tech-blog/2023/03/profiling-php8-using-reli/

勉強会では情報収集の結果を共有したり、当日担当する領域などを決めたりしていました。後は、件数をイイ感じにgrepするコマンドとかを教えてもらっていました。
反省点として、もう少し過去問の環境をスムーズに構築出来ていたら、勉強会の段階で沢山素振りして当日にチューニングの精度を上げられたのかなーと思います。

事前準備

ISUCON本番に向けて所謂秘伝のタレを作ってました。
confファイルとかMakefileとかを作ったことが全くなかったので、ベースは参考にした記事をそのまま使い、少しずつ自分たちのチームに合わせた設定にしていきました。
なお、nginx.confとmy.cnf、mysqld.cnfについてはスコアを上げるために当日に設定を色々調整したのでここで挙げた中身から色々変わっています。

Makefile
作りはしたもののチーム内の進め方の関係上あまりMakefileの設定が嚙み合わなかったような気がしました。
本当はここにxhprofが入る予定だったのですが、諸々の事情で断念。

Makefile
# デフォルトのゴールをhelpに
.DEFAULT_GOAL := help

# 問題によって変わる変数
USER:=isucon
BIN_NAME:=isucondition
BUILD_DIR:=/home/isucon/webapp/go
SERVICE_NAME:=$(BIN_NAME).php.service

DB_PATH:=/etc/mysql
NGINX_PATH:=/etc/nginx
SYSTEMD_PATH:=/etc/systemd/system

NGINX_LOG:=/var/log/nginx/access.log
DB_SLOW_LOG:=/var/log/mysql/mariadb-slow.log

.PHONY: help
help: ## このヘルプを表示
	@echo "Usage: make [target]"
	@echo ""
	@echo "使用可能なコマンド:"
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

# alp
ALPSORT=sum
ALPM="/api/isu/.+/icon,/api/isu/.+/graph,/api/isu/.+/condition,/api/isu/[-a-z0-9]+,/api/condition/[-a-z0-9]+,/api/catalog/.+,/api/condition\?,/isu/........-....-.+"
OUTFORMAT=count,method,uri,min,max,sum,avg,p99

.PHONY: alp
alp: ## 現在のNginxのアクセスログを解析して表示
	sudo alp ltsv --file=/var/log/nginx/access.log --nosave-pos --pos /tmp/alp.pos --sort $(ALPSORT) --reverse -o $(OUTFORMAT) -m $(ALPM) -q

.PHONY: alpsave
alpsave: ## ベンチマーク前の状態を保存
	sudo alp ltsv --file=/var/log/nginx/access.log --pos /tmp/alp.pos --dump /tmp/alp.dump --sort $(ALPSORT) --reverse -o $(OUTFORMAT) -m $(ALPM) -q

.PHONY: alpload
alpload: ## 保存した解析結果を表示
	sudo alp ltsv --load /tmp/alp.dump --sort $(ALPSORT) --reverse -o count,method,uri,min,max,sum,avg,p99 -q

.PHONY: slow-query
slow-query: ## slow queryを確認する
	sudo pt-query-digest $(DB_SLOW_LOG)

.PHONY: xhprof
xhprof: ## xhprofで記録する 未設定

.PHONY: access-db
access-db: ## DBに接続する
	mysql -h $(MYSQL_HOST) -P $(MYSQL_PORT) -u $(MYSQL_USER) -p$(MYSQL_PASS) $(MYSQL_DBNAME)

.PHONY: check-server-id
check-server-id: ## サーバーID確認
ifdef SERVER_ID
	@echo "SERVER_ID=$(SERVER_ID)"
else
	@echo "SERVER_ID is unset"
	@exit 1
endif

.PHONY: build
build: ## ビルド phpに変える必要あり
	cd $(BUILD_DIR); \
	go build -o $(BIN_NAME)

.PHONY: allrestart
allrestart: ## 全て再起動
	sudo systemctl daemon-reload
	sudo systemctl restart $(SERVICE_NAME)
	sudo systemctl restart mysql
	sudo systemctl restart nginx

.PHONY: deamonreload
deamonreload: ## deamonをリロード
	sudo systemctl daemon-reload

.PHONY: restartservice
restartservice: ## サービス再起動
	sudo systemctl restart $(SERVICE_NAME)

.PHONY: restartmysql
restartmysql: ## MySQL再起動
	sudo systemctl restart mysql

.PHONY: restartnginx
restartnginx: ## nginx再起動
	sudo systemctl restart nginx

.PHONY: mv-logs
mv-logs: ## ログを移動
	$(eval when := $(shell date "+%s"))
	mkdir -p ~/logs/$(when)
	sudo test -f $(NGINX_LOG) && \
		sudo mv -f $(NGINX_LOG) ~/logs/nginx/$(when)/ || echo ""
	sudo test -f $(DB_SLOW_LOG) && \
		sudo mv -f $(DB_SLOW_LOG) ~/logs/mysql/$(when)/ || echo ""

.PHONY: watch-service-log
watch-service-log: ## ログを見る
	sudo journalctl -u $(SERVICE_NAME) -n10 -f

nginx.conf
事前準備の段階では、元々Worker数変えたりコメントアウト解除したりするくらいに考えていましたが、蓋を開けてみれば本番のほとんどの時間はnginx.confを触っていました。どうして。

nginx.conf
user  www-data;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /run/nginx.pid;

# isucon14にあったinclude環境によって変えるつもりだったが変えなかったと思う
# include /etc/nginx/modules-enabled/*.conf;


# nginx worker の設定
# OS全体で扱えるファイル数 / workerプロセス数を設定する
worker_rlimit_nofile  4096;
events {
  worker_connections  1024;  # 128より大きくするなら、 max connection 数を増やす必要あり。さらに大きくするなら worker_rlimit_nofile も大きくする(file descriptor数の制限を緩める)
  # multi_accept on;         # リクエストの同時受付 error が出るリスクあり。defaultはoff。
  # accept_mutex_delay 100ms;
  use epoll; # 待受の利用メソッドを指定(基本は自動指定されてるはず)
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format ltsv "time:$time_local"
                "\thost:$remote_addr"
                "\tforwardedfor:$http_x_forwarded_for"
                "\treq:$request"
                "\tstatus:$status"
                "\tmethod:$request_method"
                "\turi:$request_uri"
                "\tsize:$body_bytes_sent"
                "\treferer:$http_referer"
                "\tua:$http_user_agent"
                "\treqtime:$request_time"
                "\tcache:$upstream_http_x_cache"
                "\truntime:$upstream_http_x_runtime"
                "\tapptime:$upstream_response_time"
                "\tvhost:$host";

    access_log  /var/log/nginx/access.log  ltsv;

    # 基本設定
    sendfile    on; # クライアントへのレスポンスにsendfileを使う
    tcp_nopush  on;
    tcp_nodelay on;
    types_hash_max_size 2048;
    server_tokens    off;
    open_file_cache max=100 inactive=20s; # file descriptor のキャッシュ。入れた方が良い。

    # コンテンツを圧縮して通信する設定 一応off
    gzip off;

    # proxy buffer の設定 ブラウザのパケット量
    proxy_buffers 100 32k;
    proxy_buffer_size 8k;

    # Keepalive 設定
    # ベンチマークとの相性次第ではkeepalive off;にしたほうがいい
    # keepalive off;

    # 同時に大量のリクエストを捌く場合、大きい値だとアップストリームとの接続が切れなくなるらしい
    keepalive_requests 20000;
    keepalive_timeout 600s;

    # 1つのHTTP/2コネクションを通して提供できるリクエストの最大数を設定
    http2_max_requests 20000;
    # フレーム処理時にデータが足りていない時にここの秒数だけ待つ
    http2_recv_timeout 600s;

    # nginxのクライアント接続の閉じ方を制御
    lingering_close always;
    lingering_time 600s;
    lingering_timeout 600s;

    # Proxy cache 設定。使いどころがあれば。1mでkey8,000個。1gまでcache。
    proxy_cache_path /var/cache/nginx/cache levels=1:2 keys_zone=zone1:1m max_size=1g inactive=1h;
    proxy_temp_path  /var/cache/nginx/tmp;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*.conf;

    upstream app {
      server 127.0.0.1:8080;
      keepalive 64;
    }

    server {
      listen 80;

      # クライアントから送信されるリクエストボディの最大許可量
      client_max_body_size 10m;
      root /home/isucon/private_isu/webapp/public/;

      # locationは環境によって設定の調整が必要そう
      location / {
        try_files $uri @app;
      }
      location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
        try_files $uri @app;
        expires max;
        add_header Pragma public;
        add_header Cache-Control "public, must-revalidate, proxy-revalidate";
        etag off;
      }
      location @app {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_pass http://app;
      }
      # デバッグ用の設定 使わないかもしれないが一応書いておく
      # location /debug/ {
      #   return 200 "access to $server_name : $server_port :  $uri";
      # }
    }
}

my.cnf mysqld.cnf
本番でほぼいじっていたファイルその2。といっても、SQLのチューニング方法によっては大きく変える必要があると思っていたので、最低限の設定しかやっていないです。(slow_queryとか)

my.cnf
#
# The MySQL database server configuration file.
#
# You can copy this to one of:
# - "/etc/mysql/my.cnf" to set global options,
# - "~/.my.cnf" to set user-specific options.
#
# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html

#
# * IMPORTANT: Additional settings that can override those from this file!
#   The files must end with '.cnf', otherwise they'll be ignored.
#

# 環境によってはここのパスを変更
!includedir /etc/mysql/conf.d/
!includedir /etc/mysql/mysql.conf.d/

[mysqld]
# スロークエリ有効化 片付けるときはここを0にする
slow_query_log = 1
# スロークエリログの場所 片付ける時はここを消す
slow_query_log_file = ’/tmp/slow.log’
# 出力するクエリの秒数 片付ける時はここを消す
long_query_time = 1
# ONにするとインデックスが効かないクエリもログに出力する 必要に応じて切り替える
log-queries-not-using-indexes = 0
# InnoDBのファイル操作でOSのキャッシュを利用しないようにする
innodb_flush_method = O_DIRECT
# or 2 ログを1秒に1回書き込むパフォーマンス重視の設定 クラッシュでトランザクションが消える
innodb_flush_log_at_trx_commit = 0
# バッファープールのサイズ 渡される環境のスペックに応じて調整
innodb_buffer_pool_size = 1G
# MySQLのログを出力する
general_log=1 log_output=FILE general_log_file=/var/log/mysql/mysql.log

mysqld.cnfについても同様で最低限の設定のみ。

mysqld
#
# The MySQL database server configuration file.
#
# One can use all long options that the program supports.
# Run program with --help to get a list of available options and with
# --print-defaults to see which it would actually understand and use.
#
# For explanations see
# http://dev.mysql.com/doc/mysql/en/server-system-variables.html

# Here is entries for some specific programs
# The following values assume you have at least 32M ram

[mysqld]
#
# * Basic Settings
#
user		= mysql
# pid-file	= /var/run/mysqld/mysqld.pid
# socket	= /var/run/mysqld/mysqld.sock
# port		= 3306
# datadir	= /var/lib/mysql


# If MySQL is running as a replication slave, this should be
# changed. Ref https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_tmpdir
# tmpdir		= /tmp
#
# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
bind-address		= 0.0.0.0
mysqlx-bind-address	= 0.0.0.0
#
# * Fine Tuning
#
key_buffer_size		= 16M
# max_allowed_packet	= 64M
# thread_stack		= 256K

# thread_cache_size       = -1

# This replaces the startup script and checks MyISAM tables if needed
# the first time they are touched
myisam-recover-options  = BACKUP

# max_connections        = 151

# table_open_cache       = 4000

#
# * Logging and Replication
#
# Both location gets rotated by the cronjob.
#
# Log all queries
# Be aware that this log type is a performance killer.
# general_log_file        = /var/log/mysql/query.log
# general_log             = 1
#
# Error log - should be very few entries.
#
log_error = /var/log/mysql/error.log
#
# Here you can see queries with especially long duration
slow_query_log         = 1
slow_query_log_file    = /tmp/slow.log
long_query_time        = 1
log-queries-not-using-indexes = 0
#
# The following can be used as easy to replay backup logs or for replication.
# note: if you are setting up a replication slave, see README.Debian about
#       other settings you may need to change.
# server-id		= 1
# log_bin			= /var/log/mysql/mysql-bin.log
# binlog_expire_logs_seconds	= 2592000
max_binlog_size   = 100M
# binlog_do_db		= include_database_name
# binlog_ignore_db	= include_database_name

skip-log-bin
skip-innodb-doublewrite
max_allowed_packet = 200M
max_connections = 5000
# ディスクイメージをメモリ上にバッファさせる値をきめる設定値 (別鯖なら搭載メモリの80%くらい)
innodb_buffer_pool_size = 3000MB
# InnoDBの更新ログを記録するディスク上のファイルサイズ(innodb_buffer_pool_sizeの4分の1程度)
innodb_log_file_size = 500MB
# innoDBの更新ログを保持するメモリ(default: 8MB)、大量のデータの更新に効く
innodb_log_buffer_size = 100MB
# 1に設定するとトランザクション単位でログを出力するが 2 を指定すると1秒間に1回ログファイルに出力するようになる
innodb_flush_log_at_trx_commit = 0
# データファイル、ログファイルの読み書き方式を指定する (OSによるディスクキャッシュとの2重キャッシュを防ぐ)
innodb_flush_method = O_DIRECT
# ORDER BYやGROUP BYのときに使われるメモリ量
innodb_sort_buffer_size = 100MB

init.sh
前日くらいにインストールとかシェル叩いて実行できた方が良いよなーと思い作りました。
やってることとしてはvimやgitをインストールして鍵置いてdumpするだけです。
ただ、dumpについては本番で失敗していたので多分何かをミスっています()

init.sh
#!/bin/bash

sudo apt install -y vim git htop

# デフォルトのエディタをvimに変更
sudo update-alternatives --set editor /usr/bin/vim.basic

# メンバーの公開鍵を設置。
cat << _EOS > /tmp/authorized_keys
ssh-rsa hogehoge
_EOS
sudo cat /home/isucon/.ssh/authorized_keys >> /tmp/authorized_keys
sudo mkdir -p /home/isucon/.ssh
sudo mv /tmp/authorized_keys /home/isucon/.ssh/
sudo chown -R isucon.isucon /home/isucon/.ssh/
sudo chmod 600 /home/isucon/.ssh/authorized_keys

# MySQLのバックアップを取得
mysqldump -uroot --all-databases > /tmp/mysql.dump

当日

会社に出社して作業スぺースを借りて参加しました。0回戦勝利。
以下、思い出せる限りで本番であった出来事とか。

9:30
配信待機
ISURIDEを見てほかの二人が「これSSEじゃない?」「ジオハッシュじゃね?見たことある」とか話していたが私はその時知らなかったため全然ピンときておらず。
(終わった後に調べました、今回の場合確かにスコア上げるためにはそこら辺の処理が結構重要でしたね…)


10:00
ISUCONスタート
私とほげほげさんがドキュメントを読み、ちょだまひろちょりさんがドキュメントを読みながら鍵配置、PHPへの言語切り替えなどを実施。
私は速攻で初期設定シェルや秘伝のタレをぶち込めるように確認したり、ドキュメントで大事そうな部分をslackに貼ったり。


11:00
初期設定シェルと秘伝のタレを流し終わった後はブラウザで動かしたりhtopやpsの結果眺めたりしながらボトルネックの特定を開始。
が、何も分からなかったのでnginxやmy.cnfの設定をもっと良い感じにできるかと思いググりながら調整することに。
(多分本番はググらずにやる事もっと決まっていた方が良かった)


12:00
worker数をいじったり静的コンテンツをgzipで返せるようにしたりproxy cacheを使えるようにしたり。
昼ご飯を食べながら設定を記述してサービスを再起動してベンチマークを回す、をひたすら繰り返す。


13:00
memcacheやAPCuを使ってメモリへのキャッシュを上手いこと出来たらスコアが伸びるかもしれないと思い、ググりながら設定していく。


14:00
おそらく設定できたが、サービスを再起動してベンチマークを回しても対してスコアが伸びなかったため、設定を見直す。
この辺で3台のEC2を分割する事を考えたがどう考えても時間がかかりすぎるしこの時点でほとんど成果が出ておらず危険すぎたので断念。
(書き忘れていましたが、3台のサーバーは一人一台で各々修正をやっていました。修正がうまいこといったらその修正を他のサーバーに適用するみたいな感じで)


15:00
nginxが何も分からなくなり絶望する。


16:00
スコアがどん底になったので一旦設定をmemcacheやAPCuの導入前まで戻す。
戻したらスコアが戻ったので導入がおそらく上手くいってなかったかもしれない。(もしくはそもそも使う場面が違った?)
修正されたソースを自分の担当サーバーに反映してもらったところ、ベンチマークが0になってしまう事態が発生。時間がなかったので結構焦る。
焦りながらインデックスをペタペタ貼る。


17:00
時間が近いのでmy.cnfでスロークエリログを吐かないようにしたりして締め作業を始める。
マニュアルを見て再試験の条件を確認して共有。他2人もベンチマーク失敗してたりずっと0になっていたりしていたので少し焦っていた。
とりあえず修正前に戻すためにソースを戻してサーバーをリブート。すると、なぜか今までMAXで1100しか出なかった自分のサーバーのスコアが2900出すという謎の事態に。
(confファイルの設定が反映されていなかった?サービス再起動ではなくサーバーの再起動が必要だったのかもしれない…)
他の2人のサーバーはベンチマークのスコアが思ったより伸びなかった(確か2000前後)ため、自分の担当サーバを最終スコアとして提出することに。
一応自分の担当サーバでやった修正(nginx.confやmy.cnf、sysctl.confの調整)を全てのサーバーに反映し、再起動出来ることを確認して終了。


18:00
ISUCON終了。
対戦ありがとうございました。

最終スコア:2,559 494位

反省点

  • やる事や担当ははっきり決めておく
  • 素振りは一杯やる
  • nginxやメモリに関するパフォーマンスチューニングをもっと勉強しておく
    (担当領域のボトルネックの特定方法、チューニング方法を知っておく)

担当なのに時間かけすぎてソースの修正に入れなかったのが本当に悔しいです…

感想

結果的に自分のnginxやmy.cnf辺りの調整が一番スコアを伸ばせていたのでチームを救う形にはなっているのですが、結構悔しい結果になってしまいました。
ただ勉強会や学びがかなり多く、実務にも活用できた内容もあったので今後どんどん活かせるようにしていきたいです。

ISUCON運営の皆様、参加者の皆様、お疲れ様でした。とても良い経験でした。
次機会あれば流石にリベンジしたいですね。またインフラやるか今度こそちゃんとアプリやるかは分かりませんが。

ご覧頂きありがとうございました。またいずれ。

Discussion