🪑

のんびりISUCONの素振りをできるようにした

2023/12/22に公開

この記事は、株式会社カオナビ Advent Calendar 2023の22日目の記事です。

はじめに

株式会社カオナビEMの@mettoboshiです。

カオナビにはWakkaやスナバというコミュニケーション促進や自己研鑽を行える仕組みがあります。
今回はこれらの制度を使ってISUCONの練習をしたので、どんなことをしたのか紹介したいと思います。

Wakka/スナバとは

Wakka is 何?

Wakkaとは趣味や関心ごとを介したい活動で、継続的かつ気軽に「部門を超えた文脈の『輪っか』を作る」というコンセプトの、カオナビ社内のサークル活動です。
有志メンバーであつまってISUCON Wakkaを設立し、現在月1-2回程度活動を行っております。

スナバ is 何?

スナバとは将来的に事業貢献・プロダクト改善に繋がる可能性がある、あらゆるインプット、アウトプット活動を指します。要するに、自己研鑽のために自由に時間を使って良いよという制度です。週2時間、1ヶ月に8時間使うことができます。

今回はこれら制度を利用してISUCONを練習環境を作ってみました。

今回のお題

今回は、社内でISUCONWakkaもできたので、
ISUCONの練習をしてみたいと思った時に最初のとっかかりになりつつ、
週1回1-2時間でのんびりやっていても積み上げていけるような方法を考えてみました。

特に以下の点を意識してみました。

  • ローカル環境で立ち上げておくことで、お金の心配をしなくて良いようにしたい
  • やったことをできる限り残しておいて以前やったことをすぐ思い出せるようにしたい

ISUCONとは

ISUCON = Iikanjini Speed Up Contestの略で、
お題となるWebサービスを決められたレギュレーションの中で限界まで高速化を図るチューニングバトルです。

先日(2023/11/25)13回目が開催されました。

私も社内メンバーとチームで参加しました。今回初めてインフラ担当をやらせてもらったのですが、色々とうまくいかないことがあり反省の多い大会になりました。

とはいえ、学びも多くとても楽しかったです。
次回は良い結果が出るように練習をせねばという気持ちです。

ISUCONを練習してみよう

参加してみると、ISUCONはとても楽しいです。ただ、楽しそうだからやってみようと思ったとしても何すればいいの?という状況になりがちです。普段から業務などで、インフラ作業や性能改善に慣れていればイメージが湧くと思いますが、そうでない場合は最初の一歩は先人の知恵を拝借するのがとても有効です。

私の個人的おすすめは、「とにかくやってみる」です。
今から始めるのであれば、ISUCON本(達人が教えるWEBパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践)の付録にあるprivate-isu攻略実践がはじめの一歩としておすすめかなと思います。

本の中では、Rubyで実装例を書かれていますが、言語を自身の得意な言語に置き換えて学習してみると、結果はともかくISUCONに参加して楽しかったと言える状態にはなれるはずです。(私がそうでした)

ISUCONのよくあるボトルネック解消パターン

すごーくざっくりですが、ISUCONでは以下のような作業をしていくにとなります。

計測 → 改善 → 計測  → 改善 ・・・

ISUCONは開催されたのち、問題の解説と講評が出ます。
例えばISUCON13の場合はこんな感じです。(練習前に見たくない場合はリンク先は見ない方が良いかも)
過去回の解説も見られるので、講評を読んでみると改善のイメージがつくと思います。

先述したprivate-isu攻略実践を試すときもそうですが、まず「計測の準備をする」。その後、「改善サイクルを回していく」という手順を踏むことになります。

計測をするための準備

計測する方法は1つではないのですが、今回はISUCON本を踏襲して以下のalp(アクセスログ集計ツール), pt-query-digest(スロークエリログを集計してくれるツール)を使用します。

実際に計測をするためには、サーバー側に以下のような設定を行う必要があります。

  • DBのSlow Queryログを出力する
  • Nginxのログの出力形式をJSONにする

これらの設定を行ってログを出力、集計できるようにすることで、どこにボトルネックがあるのかを見つけることができるようになります。

改善サイクルを回していく

計測ができるようになったら、一番のボトルネックから順番に倒していくことになります。
初手の改善でやることが多いのは以下のような改善になります。

  • DBにインデックスを貼る
  • クエリの発行回数を減らす(N+1の改善 etc)

ISUCON本番でやらなければならない改善は他にも沢山ありますが、まずこの2つを実践できるようになると練習でスコアが上げることができて、楽しいなと思えるはずです。(実際にISUCONのチームを組む場合、これらができるだけでもなんらかの役割をもてるようになるはずです)

これらを実施するぞとなった時にできるだけ簡単に環境を構築したり、修正点のログを残しておこうというのが今回のお話です。

使用したソフトウェア・ライブラリなど

今回はISUCONの練習した時できるだけgitで管理できるようにしたいなと考えました。対象は以下の3点です。

  • ミドルウェアの設定変更
  • コード変更
  • DBのスキーマ変更

そこで、以下の2つをつかって見ました。

  • mitamae
  • skeema

mitamae

mitameはmruby-cliを使ったItamaeのmruby実装です。(参考)

ISUCON12の環境構築で使われていたので、使ってみました。
バイナリを置けば動くので環境依存も少なくとても良い感じでした。
今回はmitamaeを使って、ミドルウェア類のインストールと設定ファイルの更新、ベンチマーカーを回す時のログローテートをするようにしました。

skeema

skeemaは

The best way to manage MySQL table definitions

らしいです。

ISUCONでは、DBのインデックスを貼ったりテーブル構成をかえたりすることが多いのですが、サーバー上で手動でごにょごにょすると、どこで何をしたんだっけ?みたいになりがちです。

本番はそれでも良いかもなーとも思うのですが、自分が練習でやる場合は作業ログが残したい!と思ったのでskeemaをつかってみました。

では、これらをどんな感じに使ったのかを以下で見ていきます。

環境の準備

まずは何はともあれ環境の準備からです。

今回はmatsuuさんのcloud-init-isuconを使わせていただきました。感謝。
multipassを使って簡単にprivate-isuのローカル環境を構築できます。(これを使えば、他のISUCONも練習できます)

  • 簡単に環境を構築して
multipass launch --name private-isu --cpus 2 --disk 16G --mem 4G --cloud-init standalone.cfg 22.04
  • ログインして作業ができます。
multipass shell private-isu
  • ローカルとのデータのやり取りは以下の感じで簡単にできます。
multipass transfer private-isu:/home/ubuntu/xxxx ./

便利ですね。

補足

簡単に練習したい!!と思った場合はISUNARABEもおすすめです。
本番に近い形で練習が可能です。AWSに環境を作ってチーム練習するときに使わせていただきました。初めて使った時はめっちゃ良いじゃんと感動しました。

今(2023/12/22時点)だとISUCON12予選とISUCON13の練習ができるようです。

ベンチマーカーを回す

環境が準備できたら、計測してみましょう。
private-isuでは以下のようにベンチマーカーを実行可能です

cd private_isu.git/benchmarker/
./bin/benchmarker -u ./userdata -t http://{競技用インスタンスのIPアドレス}/

ベンチマーカーを実行すると以下のような出力されます。

{"pass":true,"score":937,"success":814,"fail":0,"messages":[]}

このスコアを上げていくことがISUCONの目的になります。
とはいえ、じゃあこのあと具体的に何するの?という話になりますが、本稿では触れません。
詳細は、ISUCON本や攻略記事が沢山あるのでそちらを参考にしてみてくださいmm

mitamaeの使い方

環境が構築できてベンチマーカーが実行できたのでここからが本題です。
まずはmitamaeを使ってみましょう。
ここからcurlで必要なバイナリを落として解凍するだけで使えます。

mitamaeではライブラリ系のインストール、設定ファイルの管理、計測の前後のログのローテートみたいなことをやってみました。

フォルダ構成

フォルダ構成はこんな感じにしてみました(再考の余地はありますが)
cookbooksにレシピを置いています。

mitamae
│   │   ├── cookbooks
│   │   │   ├── alp
│   │   │   │   └── default.rb
│   │   │   ├── mysql
│   │   │   │   ├── config
│   │   │   │   │   └── mysql.conf.d
│   │   │   │   │       └── mysqld.cnf
│   │   │   │   └── default.rb
│   │   │   ├── nginx
│   │   │   │   ├── config
│   │   │   │   │   ├── nginx.conf
│   │   │   │   │   └── sites-available
│   │   │   │   │       └── isucon-php.conf
│   │   │   │   └── default.rb
│   │   │   ├── post_bench
│   │   │   │   └── default.rb
│   │   │   ├── pre_bench
│   │   │   │   └── default.rb
│   │   │   ├── percona
│   │   │   │   └── default.rb
│   │   └── roles
│   │       └── initialize.rb
│   │   └── Makefile

もろもろのインストール

pt-query-digest(percona-toolkit)のインストールレシピ例

apt-getでインストールできるようなものは、pacage 'xxx' と書くだけでインストール可能です。

package 'percona-toolkit'

alpのインストールレシピ例

alpのようなバイナリをダウンロードして解凍するような場合は以下のような感じにしてみました。
macのmultipassとawsのアーキテクチャが異なっていたため、自動で取得するバイナリを変えたくなったので、unameを見て切り替えたりしています。

# alpのバージョンとアーキテクチャを指定
VERSION = "1.0.14"

# uname -m の出力に基づいてシステムアーキテクチャを決定
UNAME_ARCH = `uname -m`.chomp
case UNAME_ARCH
when "x86_64"
  ARCH = "linux_amd64"
when "aarch64"
  ARCH = "linux_arm64"
else
  raise "Unsupported architecture: #{UNAME_ARCH}"
end

BIN_DIR = File.expand_path('../../../../bin', __FILE__)

# alpのバイナリをダウンロード
execute "Download alp" do
  command "wget https://github.com/tkuchiki/alp/releases/download/v#{VERSION}/alp_#{ARCH}.zip -O /tmp/alp_#{ARCH}.zip"
  not_if "test -e /tmp/alp_#{ARCH}.zip"
end

# unzipのインストール
package 'unzip' do
  action :install
end

# ダウンロードしたZIPファイルを解凍
execute "Unzip alp binary" do
  command "unzip /tmp/alp_#{ARCH}.zip -d /tmp"
  not_if "test -e /tmp/alp"
end

# alpバイナリを/usr/local/binに移動して実行権限を付与
execute "Install alp" do
  command "mv /tmp/alp #{BIN_DIR}/alp && chmod +x #{BIN_DIR}/alp"
  not_if "test -e #{BIN_DIR}/alp"
end

skeemaのインストールレシピ例

skeemaも同じような感じですね。

# skeemaに関する情報

# version
VERSION = "1.10.1"

# uname -m の出力に基づいてシステムアーキテクチャを決定
UNAME_ARCH = `uname -m`.chomp
case UNAME_ARCH
when "x86_64"
  ARCH = "amd64"
when "aarch64"
  ARCH = "arm64"
else
  raise "Unsupported architecture: #{UNAME_ARCH}"
end

# プラットフォーム
PLATFORM = "linux"

URL = "https://github.com/skeema/skeema/releases/download/v#{VERSION}/skeema_#{VERSION}_#{PLATFORM}_#{ARCH}.tar.gz"

# インストールするディレクトリ
BIN_DIR = File.expand_path('../../../../bin', __FILE__)

# ディレクトリの生成
directory BIN_DIR do
  action :create
end

http_request "/tmp/skeema.tar.gz" do
  url URL
  not_if "test -e /tmp/skeema.tar.gz"
end

execute "Extract Skeema archive" do
  command "tar -xzf /tmp/skeema.tar.gz -C #{BIN_DIR}"
  not_if "test -e #{BIN_DIR}/skeema"
end

ロールにまとめる

上記3つのレシピをまとめて実行するためにmitameのロールという機能を使いました。

include_recipe '../cookbooks/percona/default.rb'
include_recipe '../cookbooks/alp/default.rb'
include_recipe '../cookbooks/skeema/default.rb'

こんな感じにまとめて、以下のコマンドを実行すると一発でalp, skeema, pt-query-digestが使えるようになります。

sudo ./bin/mitamae local mitamae/roles/initialize.rb

設定ファイルの管理

mysqlやNginxの設定ファイルもgit上に残してみました。
slow query logを出力するために以下のような設定変更をしますが、

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 0

直接設定ファイルを書き込む形ではなく、設定ファイルをgit管理下にコピーしてきて修正、その後mitamaeでバックアップをとりつつ /etc/mysql/mysql.conf.d/mysqld.cnfにコピーする感じにしてみました。

mysqlの設定ファイルをコピーするレシピ例

# 現在の日時を取得
current_time = Time.now.to_s.gsub(/[- :+]/, '')

# カレントディレクトリを取得
current_dir = File.dirname(__FILE__)

# 設定ファイルの情報
filename = "mysqld.cnf"
target_directory = "/etc/mysql/mysql.conf.d/"
source_directory = "config/mysql.conf.d/"

target_path = "#{target_directory}/#{filename}"
source_relative_path = "#{source_directory}/#{filename}"
source_absolute_path = File.expand_path(source_relative_path, current_dir)

# 設定ファイルのバックアップ
execute "backup #{filename}" do
  command "mv #{target_path} #{target_path}.#{current_time}"
  only_if { File.exist?(target_path) && File.exist?(source_absolute_path) }
  not_if "diff -q #{target_path} #{source_absolute_path}"
end

# 設定ファイルのコピー
remote_file "#{target_path}" do
  owner  "root"
  group  "root"
  source source_absolute_path
  mode   "644"
  only_if { File.exist?(source_absolute_path) }
  notifies :restart, "service[mysql]"
end

# mysqlの再起動(他のリソースからの通知によってのみ実行される)
service "mysql" do
  action :restart
end

alpでログを解析できるようにするためにNginxに以下のような記載を追加する場合も同じようなレシピを作成しました。

## JSON形式でアクセスログを出力する
log_format json escape=json '{"time":"$time_iso8601",'
    '"host":"$remote_addr",'
    '"port":$remote_port,'
    '"method":"$request_method",'
    '"uri":"$request_uri",'
    '"status":"$status",'
    '"body_bytes":$body_bytes_sent,'
    '"referer":"$http_referer",'
    '"ua":"$http_user_agent",'
    '"request_time":"$request_time",'
    '"response_time":"$upstream_response_time"}';
    
    access_log /var/log/nginx/access.log json;

nginxの設定ファイルをコピーするレシピ例

# 現在の日時を取得
current_time = Time.now.to_s.gsub(/[- :+]/, '')

# カレントディレクトリを取得
current_dir = File.dirname(__FILE__)

# nginx.confの設定
filename = "nginx.conf"
target_directory = "/etc/nginx"
source_directory = "config"

target_path = "#{target_directory}/#{filename}"
source_relative_path = "#{source_directory}/#{filename}"
source_absolute_path = File.expand_path(source_relative_path, current_dir)

# nginx.confのバックアップ
execute "backup #{filename}" do
  command "mv #{target_path} #{target_path}.#{current_time}"
  only_if { File.exist?(target_path) && File.exist?(source_absolute_path) }
  not_if "diff -q #{target_path} #{source_absolute_path}"
end

# nginx.confをコピー(ファイルが存在する場合のみ)
if File.exist?(source_absolute_path)
  remote_file "#{target_path}" do
    owner  "root"
    group  "root"
    source source_absolute_path
    mode   "644"
  end
end

# isucon-php.confの設定
filename = "isuports-php.conf"
target_directory = "/etc/nginx/sites-available"
source_directory = "config/sites-available"

target_path = "#{target_directory}/#{filename}"
source_relative_path = "#{source_directory}/#{filename}"
source_absolute_path = File.expand_path(source_relative_path, current_dir)

# isucon-php.confのバックアップ
execute "backup #{filename}" do
  command "mv #{target_path} #{target_path}.#{current_time}"
  only_if { File.exist?(target_path) && File.exist?(source_absolute_path) }
  not_if "diff -q #{target_path} #{source_absolute_path}"
end

# isucon-php.confをコピー(ファイルが存在する場合のみ)
if File.exist?(source_absolute_path)
  remote_file "#{target_path}" do
    owner  "root"
    group  "root"
    source source_absolute_path
    mode   "644"
  end
end

# nginxの再起動
service "nginx" do
  action :restart
end

計測前後の操作の簡略化

ベンチマーカーの実行前にログをローテートしたり、ベンチマーカー実行後にalpやpt-query-digestでログを集計するところもmitamaeでやってみました。

ベンチマーカー実行前に実施するレシピ例

MYSQL_LOG = "/var/log/mysql/mysql-slow.log"
NGINX_LOG = "/var/log/nginx/access.log"
current_time = `date "+%Y%m%d_%H%M%S"`.strip

# MySQLログファイルの移動
execute "Move MySQL slow query log" do
  command "sudo mv #{MYSQL_LOG} #{MYSQL_LOG}.#{current_time}"
  only_if "test -f #{MYSQL_LOG}"
end

# nginxログファイルの移動
execute "Move nginx access log" do
  command "sudo mv #{NGINX_LOG} #{NGINX_LOG}.#{current_time}"
  only_if "test -f #{NGINX_LOG}"
end

# MySQLの再起動
service "mysql" do
  action :restart
end

# Nginxの再起動
service "nginx" do
  action :restart
end

ベンチマーカー実行後に実行するレシピ例

BIN_DIR = File.expand_path('../../../../bin', __FILE__)
MYSQL_LOG_BASE_DIR = "/var/log/mysql"
MYSQL_LOG="#{MYSQL_LOG_BASE_DIR}/mysql-slow.log"
NGINX_LOG_BASE_DIR = "/var/log/nginx"
NGINX_LOG = "#{NGINX_LOG_BASE_DIR}/access.log"
TIMESTAMP = `date "+%Y%m%d_%H%M%S"`.strip

# MySQLのslow-query.logを解析
execute "Analyze mysql slow-query log with pt-query-digest" do
  command "pt-query-digest #{MYSQL_LOG} > #{MYSQL_LOG_BASE_DIR}/pt-query-digest.log.#{TIMESTAMP}"
  only_if "test -f #{MYSQL_LOG}"
end

# nginxのaccess.logを解析(avg)
execute "Analyze nginx access log with alp" do
   command "#{BIN_DIR}/alp json --sort avg -r -m \"\" -o count,method,uri,min,avg,max,sum --file #{NGINX_LOG} > #{NGINX_LOG_BASE_DIR}/nginx-alp-avg.log.#{TIMESTAMP}"
#   command "#{BIN_DIR}/alp json --sort avg -r -m '/posts/[0-9]+,/@[a-z]+,/image/[0-9]+\.[a-z]+' -o count,method,uri,min,avg,max,sum --file #{NGINX_LOG} > #{NGINX_LOG_BASE_DIR}/nginx-alp-avg.log.#{TIMESTAMP}"
  only_if "test -f #{NGINX_LOG}"
end

# nginxのaccess.logを解析(sum)
execute "Analyze nginx access log with alp" do
   command "#{BIN_DIR}/alp json --sort sum -r -m \"\" -o count,method,uri,min,avg,max,sum --file #{NGINX_LOG} > #{NGINX_LOG_BASE_DIR}/nginx-alp-sum.log.#{TIMESTAMP}"
#   command "#{BIN_DIR}/alp json --sort sum -r -m '/posts/[0-9]+,/@[a-z]+,/image/[0-9]+\.[a-z]+' -o count,method,uri,min,avg,max,sum --file #{NGINX_LOG} > #{NGINX_LOG_BASE_DIR}/nginx-alp-sum.log.#{TIMESTAMP}"
  only_if "test -f #{NGINX_LOG}"
end

これらのレシピの実行はMakefileで実行できるようにしておきました。
これで、mitamaeのことはなにも覚えてなくてもなんとかなるようになりました。

Makefileの例

.PHONY: initialize

initialize:
	sudo ./bin/mitamae local --log-level=debug mitamae/roles/initialize.rb

configure-mysql:
	sudo ./bin/mitamae local --log-level=debug mitamae/cookbooks/mysql/default.rb

pre-bench:
	sudo ./bin/mitamae local --log-level=debug mitamae/cookbooks/pre_bench/default.rb

post-bench:
	sudo ./bin/mitamae local --log-level=debug mitamae/cookbooks/post_bench/default.rb

list:
	@grep '^[^#[:space:]].*:' Makefile

このようにもろもろをmitamaeで管理することで、ソフトウェアのインストールや設定、ベンチマーカーの実行、alpの集計のやり方のログを残すことができるようになりました。

skeemaの使い方

skeemaはサーバー上でDDLを作成しつつ、ローカルで検証、サーバーに適用みたいな感じで使ってみました。今回はDDLを作成してインデックスを貼る例をあげてみます。

DDLの作成

サーバー上でskeema initを実行して

skeema init -h my.db.hostname -u root -p -d schemas --schema dbname

以下のようなDDLが生成されるのでgitにあげる

├── schemas
│   └── isuconp
│       ├── comments.sql
│       ├── posts.sql
│       └── users.sql

インデックスの作成

commentsテーブルの'post_id'にインデックスを貼りたいなーと思ったらローカルでDDLを修正して

  • before
CREATE TABLE `comments` (
  `id` int NOT NULL AUTO_INCREMENT,
  `post_id` int NOT NULL,
  `user_id` int NOT NULL,
  `comment` text NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
  • after
CREATE TABLE `comments` (
  `id` int NOT NULL AUTO_INCREMENT,
  `post_id` int NOT NULL,
  `user_id` int NOT NULL,
  `comment` text NOT NULL,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  INDEX `idx_post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

差分をチェックして

skeema diff

適用する

skeema push

みたいなことができます。
これらの処理をとりあえずローカルで試してexplainで確認していけそう!ってなったら、
gitにDDLをあげて、サーバ上で最新のDDLを落としてきて

skeema pull

とかできるのです。便利。
これを使えば、DB関係の変更の記録が残せるようになります。

まとめ

今回は、ISUCONの攻略というより、ISUCONを一人でのんびり遊ぶための準備についてまとめてみました。

ISUCONで勝つのは難しいですが、楽しむだけなら割と簡単です。
とはいえ、隙間時間で練習すると常に環境構築だけしかしていないなーとなりがちです。
このようなツール群を自分で作成して別リポジトリで管理しておいて、ISUCON素振りをやるときにサクッと環境を作れるようにしてみてはいかがでしょうか。

株式会社カオナビでは一緒に働く仲間を募集しています。カジュアル面談も行っていますので、ご興味がある方は気軽にお声がけください。

https://corp.kaonavi.jp/recruit/recruitment/

Discussion