🏃

勘測ではなくて観測せよ! チューニングハッカソン優勝の備忘録

に公開

パフォーマンスチューニングのハッカソンで優勝しました!☺️

https://aidi.hiroshima-u.ac.jp/news/post-1671/

備忘録として、事前に知っておけばもっとスムーズに改善できたと思う部分についてまとめました。
主に Web 開発経験はあるが、パフォーマンスチューニングは初めてという方を想定して書いています!

(※)この記事は、広島大学 HiCoder & ゲーム制作同好会 GSD Advent Calendar 2025 の 6 日目の記事です。

はじめに

今回参加したのは、Tuning the backend Contest(以下、チューニングハッカソン)という広島大学株式会社ドリーム・アーツが共同で開催する、バックエンドのパフォーマンスチューニングを競うコンテストです。3 人 1 組のチームを編成し、2日間でプログラミングの高速化・効率化等のパフォーマンスを競い合いました。

結果は、550 点超えで優勝しました。

技術構成

項目 技術
バックエンド Go
フロントエンド Next.js + TypeScript
データベース MySQL
リバースプロキシ nginx
実行環境 Docker (docker-compose)

本番環境、ローカル環境ともに、バックエンド、フロントエンド、MySQL、nginx がそれぞれ個別の Docker コンテナとして起動していました。

リポジトリ

実際に私のチームが改善したリポジトリはこちらです。
問題の概要やプルリクエストで実際の変更点が理解の助けになれば幸いです!

勘測ではなくて観測する

まず何から着手すればいいのか

それは観測です!観測してください!
システムの状態を観測可能状態にしてください!

「N+1 問題の解消」「アルゴリズムの最適化」「キャッシュの活用」など、改善できそうな箇所はたくさん思い浮かぶと思います。確かにこれらを改善すればパフォーマンスは向上するかもしれません。

それでも、観測してください!

当てずっぽうで改善していってもスコアは上がりません。医者が治療する前に検査をするように、まずはシステムの現在の状態を観測して、ボトルネックになっている箇所を適切に改善する必要があります。

(勉強してく...)システムの状態を観測してください!

チューニングの流れ

チューニングハッカソンでは、ボトルネックを解消してパフォーマンスを改善するために、以下の流れを繰り返します。

  1. ボトルネックを探す(観測する)
  2. 該当箇所のコードを修正する
  3. パフォーマンスが改善されたかを観測する

ただ、全体のパフォーマンススコアだけでは、ボトルネックの特定は困難です。そのため、リクエスト送信からレスポンスまでの CPU 使用率や応答時間を観測する必要があります。

本コンテストでは、Go のライブラリである Jaeger が提供されたコードに事前に組み込まれており、各リクエストの応答時間を簡単に把握できたため、追加の実装は必要ありませんでした。

反省点 1

既存ツールを活用して観測可能な状態にすることを優先すべきだった

私は、事前の学習で「アクセスログを集計する」という方法が頭にあったため、nginx のアクセスログや応答時間の遅い SQL などを集計するコードを自前で書いてしまいました。しかし、実際には以下の方法で十分でした。

  • Jaeger の利用: どの API がどのくらい応答時間が遅いかなどの全体感を把握できました
  • Docker の log 確認: 個別のアクセスログであれば Docker の log を確認することで、nginx も SQL もバックエンドの挙動を確認できました

この 2 点が最初から可能だったため、別途アクセスログを集計するコードを書く必要はありませんでした。事前の勉強した方法に固執しすぎて、他に簡単な方法がないかを考えていませんでした 😭

本番とローカルの差分を意識する

本番とローカルって何が違うの?

本番環境とローカル環境は、スコアの観測方法、MySQL の設定、nginx の設定など、多くの点で異なる可能性があります。「全然違う」という前提で確認するのが良いと思います。

実際に本コンテストでも、nginx の設定と MySQL の設定が異なっていました。

以下では、クライアント → nginxアプリケーションサーバー → MySQL の 2 つの観点から、設定の違いによる影響を確認します。

リクエストからレスポンスまでの流れ

クライアント → nginx

本コンテストでは、本番環境の負荷テストがローカル環境の負荷テストよりも想定される同時接続ユーザー数の 100 倍多くなる設定になっていました。

この違いにより、ローカル環境ではスコアは高く出るが、本番環境ではほとんどスコアが上がらないという現象が起きました。

本番環境
本番環境では、120 秒間は 1VU (Virtual User) で開始し、その後 60 秒かけて 100VU まで増やし、さらに 60 秒間 100VU を維持します。合計 240 秒(4 分)のテストが実行されます。

{
  "exec": "userJourneyScenario2",
  "executor": "ramping-vus",
  "startVUs": 1,
  "stages": [
    { "duration": "120s", "target": 1 },
    { "duration": "60s", "target": 100 },
    { "duration": "60s", "target": 100 }
  ],
  "startTime": "0s",
  "gracefulStop": "60s"
}

ローカル環境
ローカル環境では、1VU で固定回数の繰り返し実行を行います。100,000 回の繰り返しを実行しますが、最大 30 秒で終了します。

{
  "exec": "userJourneyScenario2",
  "executor": "shared-iterations",
  "vus": 1,
  "iterations": 100000,
  "startTime": "0s",
  "maxDuration": "30s"
}

修正箇所

nginx の設定ファイル: 同時接続可能数を追記

events {
    worker_connections 1024;
}

この修正により、本番環境のスコアが 15 点から 330 点に大幅に向上しました(ローカル環境は 550 点の状態のまま)。

アプリケーションサーバー → MySQL

クライアントから nginx への同時アクセスユーザー数が多くなると、DB に送られるクエリも増加します。そのため、本番環境では、同時に DB にアクセス可能な接続数を増やす必要があります。

修正箇所

  1. MySQL の設定ファイル(サーバー側): MySQL サーバーが受け入れることができる最大接続数を追記
max_connections = 151
  1. Go アプリケーション側の接続プール設定(クライアント側): アプリケーション側で管理する接続プールを増やす
// アプリケーションから同時に開くことができる接続の最大数
dbConn.SetMaxOpenConns(100)

// 使用されていない接続をプールに保持する最大数(40接続まで)
// これにより、接続の作成・破棄のオーバーヘッドを削減できます
dbConn.SetMaxIdleConns(40)

補足
MySQL の max_connections はサーバー側の制限で、Go の SetMaxOpenConns はアプリケーション側の制限です。複数のアプリケーションインスタンスが存在する場合、それぞれが SetMaxOpenConns で設定した接続数まで接続できるため、合計が max_connections を超えないように設定する必要があります。

この修正により、本番環境のスコアが 330 点から 550 点に大幅に向上しました(ローカル環境は 550 点の状態のまま)。

反省点 2

以下の点を意識していれば、より効率的に改善できたと思います。

  • 本番環境とローカル環境の設定の違いを確認する
  • 具体的なエラーの原因をログから突き止める
  • スコアの計算方法を確認する

おわりに

この記事を読んだら、チューニングでは「観測してください」

最後に、参考になった書籍をご紹介します。

達人が教える Web パフォーマンスチューニング 〜ISUCON から学ぶ高速化の実践

Discussion