【ISUCON14対策】private-isuチャレンジ記
はじめに
2024/12/8(日)に開催のISUCON14へ向けて練習した記録です。
お題は private-isu
と呼ばれるISUCON練習問題の中でも、特にメジャーなものを解くことにしました。
結論として、最終スコアは44,2852点でした。ここに辿り着く道のりは険しかったです💦
また、パフォーマンス改善タスクを求められる現場の方々にも、実際に役立つテクニックも恐らくあるはずで、興味のある方、色んな方に読んでいただけると嬉しいです。
改善したことだけ知りたい方は スコア推移 から読むのをオススメします
筆者のISUCON歴
わたしの過去のISUCON歴としては、ISUCON12は最終スコア0
、ISUCON13では最終スコアが初期スコアを下回り、スコアの出た中で下から3番目という悲惨な経歴を持ってます!w
元々ISUCONにはとても興味があり、モチベーションを高く勉強して挑んできましたが、やはり技術力がまだ未熟で上位スコアには程遠い戦績でした...
今年は、秋に転職して @nwiizoさん @megumish_unsafeさんと出会って、新たなチームとして一緒に ISUCON へ参加いただくことになりました!お二人とも各分野で活躍されてる方で、一緒に参加できることが楽しみです👍
今年の目標
今年の目標は、『上位15%』 です
3人で話し合った結果、もし上位15%を取れなかった場合...
私たちは12月に解散します!!普通の女の子に戻りたいんです!
ソースはこちら ⇒ ニッカン名言集(^。^)
自分が生まれてない時代の名言を引っ張ってきた
というわけで、今年は上位目指すぞっ!とモチベーションをもって、練習に取り組み始めました。
早速、パフォーマンスチューニングした事例を紹介していきます。
環境について
AWS環境構築
自作したCloudFormationで環境を用意しました。
- 作成されるリソース
インスタンス名 | 役割 |
---|---|
isu1 | サーバー1 |
isu2 | サーバー2 |
isu3 | サーバー3 |
isu-bench | ベンチマーカー |
AMIはprivate-isu
公式で用意されているものを使ってます。
サーバー環境構築
- 一番初めだけ各サーバーにSSHログイン後、以下コマンド実行する
# SSH後に実行。デプロイキー作成
$ cd ~ && \
git clone https://github.com/melanmeg/private-isu-challenge.git && \
mv private_isu private_isu.bk && \
mv private-isu-challenge private_isu && \
ssh-keygen -t ed25519 -C "" -f ~/.ssh/id_ed25519 -N "" && \
sudo apt update -y
# GOROOTのフォルダが空だったので、そのような場合にGoをインストールする
$ sudo rm -rf /usr/local/go && \
TAR_FILENAME=$(curl 'https://go.dev/dl/?mode=json' | jq -r '.[0].files[] | select(.os == "linux" and .arch == "amd64" and .kind == "archive") | .filename') && \
URL="https://go.dev/dl/$TAR_FILENAME" && \
curl -fsSL "$URL" -o /tmp/go.tar.gz && \
sudo tar -C /usr/local -xzf /tmp/go.tar.gz && \
rm -f /tmp/go.tar.gz && \
cat <<EOF >> ~/.bashrc
export GOROOT=/usr/local/go
export GOPATH=$HOME/go
export PATH=/usr/local/go/bin:$PATH
EOF
VSCodeを使ってデバッグなどしたい方は、初めにVSCodeで
Go: Install/Update Tools
しておく。
Ansibleで構築しています。
ソースコードはここに配置されてます
https://github.com/melanmeg/private-isu-challenge/tree/main/ansible
やっていること
以下ファイルで、どのサーバーにてどのタスクを実行するかを定義してます。
基本、全サーバーに同じ処理を競技開始時に実行させる想定です。
ansible/playbook.yml
- name: all
hosts: all
gather_facts: false
user: "{{ ISUCON_USER }}"
become: true
max_fail_percentage: 0
vars_files:
- env.yml
tasks:
- include_tasks: tasks/tools/common.yml
- include_tasks: tasks/tools/nginx.yml
- include_tasks: tasks/tools/mysql.yml
- include_tasks: tasks/tools/memcached.yml
- include_tasks: tasks/tools/netdata.yml
- include_tasks: tasks/tools/graphviz.yml
- include_tasks: tasks/tools/pprotein.yml
- include_tasks: tasks/tools/sysctl.yml
- include_tasks: tasks/isu/isu_deploy.yml
- include_tasks: tasks/isu/isu_challenge.yml
- name: isu1
hosts: isu1
gather_facts: false
user: "{{ ISUCON_USER }}"
become: false
max_fail_percentage: 0
vars_files:
- env.yml
tasks:
- include_tasks: tasks/tools/collect.yml
- include_tasks: tasks/tools/fetch.yml
やっていることで特に重要なタスクとしては、
-
nginx
にアクセスログを設定 -
mysql
にslowlog出力、すべてのネットワーク許可設定 -
memcached
にすべてのネットワーク許可設定 -
netdata
をインストール -
pprotein-agent
をインストール
Ansibleがインストールされているかつ hosts
ファイルを用意されている上で、ローカルマシンから以下コマンドで実行ができます。
[isucon]
isu1 ansible_host=x.x.x.x
isu2 ansible_host=x.x.x.x
isu3 ansible_host=x.x.x.x
$ ansible-playbook --key-file ~/.ssh/[Githubに登録した鍵に対する秘密鍵] -i hosts ./playbook.yml -C
以下CIでも実行できるようにしてます
https://github.com/melanmeg/private-isu-challenge/blob/main/.github/workflows/isu.yml
計測について
pproteinとは
pproteinを使ってみました。これは、ISUCONで常勝常連のNaruseJunチームさんが作られたツールで alp
, slp
, fgprof
の計測結果を一元して確認できる便利ツールです。
計測サイクルがかなり楽になるので、本番でも利用するつもりです!
- 参考記事
pprotein、phpMyAdminの用意
これもCloudFormationで、同じネットワーク内に一発で用意できるようにしました。
isu-measure インスタンスが作成されます。
デプロイ後、自動的にpprotein、phpmyadminがdocker-compose
で起動する仕組みとなってます。
そして、予め .ssh/config
に isu-measure
を設定しておく。
- 端末で実行して、ポートフォワーディング
ssh -NL 9000:localhost:9000 isu-measure # pprotein
ssh -NL 8080:localhost:8080 isu-measure # phpmyadmin
CloudFormationが完了し、数分~数十分でcloud-init処理(初期起動時の処理)が完了すると、 http://localhost:9000
、http://localhost:8080
にそれぞれアクセスできます。
pprotein の 画面
groupタブ
事前に pprotein-agent
がインストールされているかつ各種ログ出力設定がなされている状態で、「Collect」をクリックすると alp
, slp
, fgprof
の計測が開始されます。
settingタブ
-
group/targets
の設定
[
{
"Type": "pprof",
"Label": "webapp",
"URL": "http://10.1.1.11:6060/debug/pprof/profile",
"Duration": 60
},
{
"Type": "httplog",
"Label": "nginx",
"URL": "http://10.1.1.11:19000/debug/log/httplog",
"Duration": 60
},
{
"Type": "slowlog",
"Label": "mysql",
"URL": "http://10.1.1.11:19000/debug/log/slowlog",
"Duration": 60
}
]
-
httplog/config
の設定
ポイントとしては、matching_groups
でエンドポイントごとにグループ化して計測結果を表示することができます。
sort: sum
reverse: true
query_string: true
output: count,5xx,4xx,method,uri,min,max,sum,avg,p99
matching_groups:
- ^/image/[0-9]+$
- ^/image/[0-9]+\..+$
- ^/posts/[0-9]+$
- ^/posts.*
- ^/@\w$
-
slowlog/config
の設定- デフォルトで
phpMyAdmin の 画面
事前にmysql設定を bind-address = 0.0.0.0
などとしてすべてのネットワークを許可しておくことで、プライベートIPの 10.1.1.11
(isu1) にアクセスします。
ISUCONではDBにMySQLがよく使われます。
DBのコマンドを思い出すよりも、自分はUIで確認できる方が圧倒的に手間が省けます。
Go にプロファイル用コードを仕込む
echo, gin, mux, standalone で統合可能。
参考: https://github.com/kaz/pprotein/tree/master/integration
- echov4の場合
import (
"github.com/kaz/pprotein/integration/echov4"
)
func main() {
echov4.EnableDebugHandler(e)
}
- standaloneの場合
import (
"github.com/kaz/pprotein/integration/standalone"
)
func main() {
standalone.Integrate(":6060")
}
CI
Github Actionsでボタンを押すだけでベンチマークを回せるように設定しています。
name: "Bench"
on:
workflow_dispatch:
jobs:
Bench:
name: "Bench Job"
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Exe
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ vars.HOST_BENCH }}
username: isucon
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
sudo -u isucon /home/isucon/private_isu.git/benchmarker/bin/benchmarker -u /home/isucon/private_isu.git/benchmarker/userdata -t http://${{ vars.HOST1 }}
予めActionsにSSH_PRIVATE_KEY
(秘密鍵), HOST_BENCH
(ベンチマーカー), HOST1
(サーバー1)変数を登録しておいた上で、CI実行するとベンチが走る実装となってます。
.github/workflows/isu.yml
でisu1,2,3に対してAnsible実行する定義もあったりします。
スコア推移
基本的に、『達人が教えるWebパフォーマンスチューニング〜ISUCONから学ぶ高速化の実践』いわゆるISUCON本を参考にしながら進めていきました。
ISUCON本の実装例はRubyですので、Goで実装できるボトルネックを改善していってます。
1. 初期スコア(4193)
初期状態での slp
の結果です。
2. 4193 → 34720
- commentsテーブルに
comments_idx_1
インデックスを追加する
以下クエリが最も遅かったので、EXPLAINで確認してみます。
SELECT * FROM `comments` WHERE `post_id` = N ORDER BY `created_at` DESC LIMIT N
実行計画
mysql> EXPLAIN SELECT * FROM comments WHERE post_id = 100 ORDER BY created_at DESC \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: comments
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 99667
filtered: 10.00
Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)
Using where; Using filesort
となっており、インデックスで改善できるか考えます。
- まずは、
post_id
カラムに対してインデックスを貼るとUsing where
が消えました。
これはISUCON本によると、二分探索で探すようになるためrows: 5
探索が5行で済みます。
alter table comments add index comments_idx_1 (post_id);
実行計画
mysql> EXPLAIN SELECT * FROM comments WHERE post_id = 100 ORDER BY created_at DESC \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: comments
partitions: NULL
type: ref
possible_keys: comments_idx_1
key: comments_idx_1
key_len: 4
ref: const
rows: 5
filtered: 100.00
Extra: Using filesort
1 row in set, 1 warning (0.00 sec)
テーブルスキーマ確認
mysql> SHOW CREATE TABLE comments \G
*************************** 1. row ***************************
Table: comments
Create Table: 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`),
KEY `comments_idx_1` (`post_id`)
) ENGINE=InnoDB AUTO_INCREMENT=100020 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
- 次に
Using filesort
を改善します。
こちらもISUCON本曰く「対象の件数(rows)は多くないが、ソート処理はデータベースにとって負担が大きい処理の1つである」とのことです。
以下のように複合インデックスへと貼り直すとcreated_at
でソートがなされ、Using filesort
が消えました。
alter table comments add index comments_idx_1 (post_id, created_at);
実行計画
mysql> EXPLAIN SELECT * FROM comments WHERE post_id = 100 ORDER BY created_at DESC \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: comments
partitions: NULL
type: ref
possible_keys: comments_idx_1
key: comments_idx_1
key_len: 4
ref: const
rows: 5
filtered: 100.00
Extra: Backward index scan
1 row in set, 1 warning (0.01 sec)
- 最後に
Backward index scan
を消します。 これは単にインデックス降順にすれば消えます。
alter table comments add index comments_idx_1 (post_id, created_at DESC);
実行計画
mysql> EXPLAIN SELECT * FROM comments WHERE post_id = 100 ORDER BY created_at DESC \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: comments
partitions: NULL
type: ref
possible_keys: comments_idx_1
key: comments_idx_1
key_len: 4
ref: const
rows: 5
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
消えました!
修正後の計測結果
3. 34720 → 34846
- 静的ファイルをキャッシュする
webapp/public配下の静的ファイルのキャッシュを試します。
server {
location ~ .*\.(ico|css|js|img) {
expires 1d;
add_header Cache-Control public;
}
...
}
元々この時点でボトルネックではなかったため、スコアはほぼ変わらず。
修正後の計測結果
nginxでico
,css
,js
,img
拡張子はキャッシュする設定を入れることで、その拡張子に対して、alpの結果はアクセス時間は0
秒となりました。
4. 34846 → 54217
- 画像ファイルを取得と同時にすべてディスクに書き出し、
/image/*
にマッチするリクエストで該当ファイルが存在する場合はそのファイルを返す
Go実装 writeImageToFile()
func writeImageToFile(post Post) error {
// ディレクトリパスを指定
dirPath := "../public/images"
// ディレクトリが存在しない場合は作成
if err := os.MkdirAll(dirPath, os.ModePerm); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
// MIMEタイプに基づいてファイル拡張子を決定
var ext string
switch post.Mime {
case "image/jpeg":
ext = ".jpg"
case "image/png":
ext = ".png"
default:
return fmt.Errorf("unsupported mime type: %s", post.Mime)
}
// ファイル名をpost IDに基づいて作成
fileName := fmt.Sprintf("%d%s", post.ID, ext)
// 完全なファイルパスを生成
filePath := filepath.Join(dirPath, fileName)
// ファイルにバイナリデータを書き出す
err := os.WriteFile(filePath, post.Imgdata, 0644)
if err != nil {
return fmt.Errorf("failed to write image to file: %v", err)
}
fmt.Printf("Image successfully written to %s\n", filePath)
return nil
}
Go実装 getImage()
func getImage(w http.ResponseWriter, r *http.Request) {
...
post := Post{}
err = db.Get(&post, "SELECT * FROM `posts` WHERE `id` = ?", pid)
writeImageToFile(post) // ← 追記 取得と同時に画像ファイルは書き出しする
修正後の計測結果
この実装で、/image/*
にマッチするリクエストのアクセス時間は、大幅に改善されました。
5. 54217 → 72393
-
posts
,users
テーブル を JOINしてLIMIT 20
で必要最小行数を取得する(N+1の改善)
まず、ホームページの1ページに表示される投稿の数は20件です。
L390のクエリを posts
と users
を JOINするクエリに変更する
- err := db.Select(&results, "SELECT `id`, `user_id`, `body`, `mime`, `created_at` FROM `posts` ORDER BY `created_at` DESC LIMIT 20")
+ err := db.Select(&results, `
+ SELECT p.id, p.user_id, p.body, p.mime, p.created_at
+ FROM posts AS p
+ JOIN users AS u ON (p.user_id = u.id)
+ WHERE u.del_flg = 0
+ ORDER BY p.created_at DESC
+ LIMIT 20
+ `)
実行計画
mysql> EXPLAIN
-> SELECT p.id, p.user_id, p.body, p.mime, p.created_at
-> FROM posts AS p
-> JOIN users AS u ON (p.user_id = u.id)
-> WHERE u.del_flg = 0
-> ORDER BY p.created_at DESC
-> LIMIT 20;
+----+-------------+-------+------------+--------+---------------+---------+---------+-------------------+------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+---------------+---------+---------+-------------------+------+----------+----------------+
| 1 | SIMPLE | p | NULL | ALL | NULL | NULL | NULL | NULL | 9791 | 100.00 | Using filesort |
| 1 | SIMPLE | u | NULL | eq_ref | PRIMARY | PRIMARY | 4 | isuconp.p.user_id | 1 | 10.00 | Using where |
+----+-------------+-------+------------+--------+---------------+---------+---------+-------------------+------+----------+----------------+
2 rows in set, 1 warning (0.00 sec)
6. 72393 → 82680
- postsテーブルに
posts_idx_1
を追加する
先の実行計画でpostsテーブルにて Using filesort
となっていたので、ORDER BY
でソートしている created_at
に対してインデックスを貼ります。
alter table posts add index posts_idx_1 (created_at DESC);
mysql> EXPLAIN SELECT p.id, p.user_id, p.body, p.mime, p.created_at FROM posts AS p JOIN users AS u ON (p.user_id = u.id) WHERE u.del_flg = 0 ORDER BY p.created_at DESC LIMIT 20;
+----+-------------+-------+------------+--------+---------------+---------------------+---------+-------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+---------------+---------------------+---------+-------------------+------+----------+-------------+
| 1 | SIMPLE | p | NULL | index | NULL | idx_created_at_desc | 4 | NULL | 199 | 100.00 | NULL |
| 1 | SIMPLE | u | NULL | eq_ref | PRIMARY | PRIMARY | 4 | isuconp.p.user_id | 1 | 10.00 | Using where |
+----+-------------+-------+------------+--------+---------------+---------------------+---------+-------------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
修正後の計測結果
Extraが NULL
となり、rowsも 199
になりました。
7. 82680 → 86961
- commentsテーブルに
comments_idx_2
を追加する
先のslowlog結果の内、上位2位のクエリに対して実行計画をします。
mysql> EXPLAIN SELECT COUNT(*) AS `count` FROM `comments` WHERE `user_id` = 382 \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: comments
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 99590
filtered: 10.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
alter table comments add index comments_idx_2 (user_id);
mysql> EXPLAIN SELECT COUNT(*) AS `count` FROM `comments` WHERE `user_id` = 382 \G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: comments
partitions: NULL
type: ref
possible_keys: comments_idx_2
key: comments_idx_2
key_len: 4
ref: const
rows: 102
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.00 sec)
rows: 102
となり、 Using where
がなくなりました。
Using index
は、インデックスに格納されている情報だけでクエリを完結できるという意味のようです。
修正後の計測結果
8. 86961 → 171163
- N+1クエリ結果と遅いクエリ結果をmemcachedでキャッシュする
先のslowlogで遅いクエリは N+1 になっている実装のクエリになります。
変更差分が大きいため、修正コミットのリンクを貼ります。
slowlog上位2,3位については makePosts
関数内でキャッシュ利用することでループによる実行処理を何度もせずに済むように修正しています。
slowlog上位1位についても、 getPosts
関数内でキャッシュ利用すると少し高速されたのでキャッシュを利用する方針で修正しました。
シリアライズには、 gob
というライブラリを使いました。 go-json
よりも高速だったのでこちら使いました。
この辺は ChatGPT
や Github Copilot
を使えばすぐ実装できます。
修正後の計測結果
この時点でのCPU使用率は、ベンチマーク実行中で約45%くらい
修正後時点でhttplogは、他の計測結果に比べて特に遅いなと、今更になぜだろうと気になりました。
恐らくフロント処理が遅いだろうと目星を付け、 Chrome
の Performance
で適当な操作を計測しました。
`Chrome` の `Performance` 計測結果
Layout shift
処理が特に遅いように見えました。
layout shifts
は、ウェブページ上の要素がレンダリング後に位置を移動することを指します。
ChatGPT回答
回答を見てもよくわかりませんでしたし、計測方法や本当にボトルネックと言えるのかもちょっと自信ないです💦
また、デザインの変更はISUCONのレギュレーションに引っかかりそうな気もしますが、試しにデザインを変えてでも高速化が見込めるならやってみたいとは感じました。
もし、有識者がいらっしゃれば教えて頂きたいですw
9. 171163 → 179618
- ログをやめてみた
10. 179618 → 207649
- グローバルIPでなく内部ネットワークのIPに対してベンチを実行する。nginxにkeepalive設定をする。
keepalive_requests 10000;
upstream app {
server localhost:8080;
keepalive 100;
keepalive_requests 10000;
}
...
修正後の計測結果
11. 207649 → 437170
- サーバー分割
以下構成でサーバーを分割すると、一気に43万点に上がりました。
アプリを2つに分散したその分、特に伸びたんだと思います。
- 構成
ホスト名 | ミドルウェア |
---|---|
isu1 | nginx、アプリ |
isu2 | memcached、アプリ |
isu3 | mysql |
12. 437170 → 442852
- 不要なログ、サービスを止める
やってみた印象について
- 序盤は各サーバーでそれぞれ調査して、やはりサーバー分割は終盤で良いのかな
- N+1の改善は、クエリ結果をmemcachedでキャッシュできそうなら、コードを修正して直せる場合でも、キャッシュした方が修正が少ないかつ手早く実装ができそう
- 画像ファイル書き出しやJOINの改善は、手札として頭に入れておくと良さそう
まとめ
-
private-isu
は何度やっても学びがあるとよく言われるようですが、今回も実感できました。 - INDEXを適切に貼ることで、スコアを上げることにも特に繋がるのかなと思いました。本番でもどのようにINDEXを貼ると良いかを見つけることが重要そうです。
-
private-isu
以外の問題は練習してないけど、この準備したことが結果に繋がることを信じたいです。 - その他改善余地としては、
nginx
,mysql
のパラメータ調節、コマンド呼び出し箇所の呼び出しをやめる、静的ファイルの事前zip化、html/template処理をやめる、Decode処理をどうにかする、その他キャッシュが良さそうな処理をキャッシュ化 などがありそうではある。※100万点まで到達されている方もいるので、そこまで改善もできるはず。
最後に
余談です
私が昔こういう記事/リポジトリがあったら助かったなぁという記事を書く意識を持ちました。「親切でシンプルな記事を目指す」とプロフにも書いているので、なるべくそういう記事にはしたいと思ってます。
勉強したての頃はわからないことだらけだったので、内容がシンプルで手順を辿れば動作してくれると助かるのかなと考えます。
自己満ですが、当時の自分の気持ちと同じ方がいて私の記事が意図通りに役立てば幸いです。
話しが逸れましたが、ISUCONがんばってきます!
本番では、最終ベンチマークでスコア 0
にならないことを祈ります🙏
Discussion