【ISUCONで始める】New Relicによるパフォーマンスチューニングを体験してみよう！

# はじめに

この記事では、ISUCON[^1]の過去問（ISUCON13）を題材に、New Relic を活用したパフォーマンスチューニングを実践してみます。

主な想定読者は New Relic をまだ触ったことがない方で、この記事をきっかけに
- 「New Relic 触ってみたい！」
- 「New Relic を引っさげてISUCONに参加してみたい！」

と思っていただければ嬉しいです！

記事の前半では環境構築の流れを一通りご説明し、後半では実際にチューニングを行います。

New Relic はフリープランを利用します。
フリープランの概要については「[New Relic フリープランで始めるオブザーバビリティ！ #初心者 - Qiita](https://qiita.com/ktst79/items/5ee74b34e6adb8a7a6fa)」をご参照ください。簡単にまとめると以下のような太っ腹仕様になっています。

- 全機能を利用可能な`Full platform user`を1名分使える
- 100GB/月までデータ転送が可能
- クレジットカードの登録不要

### :warning: 注意事項
- ISUCONの過去問環境を構築するにあたりAWSを利用しますが、利用料金が発生する可能性があることにご留意ください。
- ISUCONの言語には`Python`を使用します。
- 本記事で使用しているローカルPCのOSは`macOS Sequoia`です。


# 環境構築

それでは早速、環境構築から始めましょう :rocket: 

## ISUCON過去問環境の構築

[公式で紹介されている環境構築方法](https://isucon.net/archives/54946542.html)のうち、今回はAWSを使って環境を構築します。

まず、「[matsuu/aws-isucon: ISUCON過去問をAWS環境で構築するための一式](https://github.com/matsuu/aws-isucon)」から`ami-006d211cb716fe8a0`を選択、「**AMIからインスタンスを起動**」をクリックし EC2インスタンスを起動します。
![スクリーンショット 2024-12-01 17.49.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/fc3e3e75-7fb6-675b-2249-1f847406fe11.png)

参考までに、筆者は以下の設定でインスタンスを作成しました。
- インスタンスタイプは`c5.large`を選択
- 「**自分のIP**」からの SSH トラフィックを許可
- インターネットからの HTTPS トラフィックを許可

（また、インスタンス作成後にEIPを関連付けることで、インスタンスを停止してもIPアドレスが変わらないようにしました。）

インスタンスの作成が完了したら、ローカルPCのhostsファイルに以下を追記しておきます。
```
<YOUR_EC2_IP> https://pipe.u.isucon.local/

(例) 192.0.2.1 https://pipe.u.isucon.local/
```

続いて、 「[ISUCON13 当日マニュアル](https://github.com/isucon/isucon13/blob/c52b359fc6e733e1193ac8e9835bea23856566e7/docs/cautionary_note.md)」を参考にしながら初期設定を行います。
1. SSHでEC2にログインします。
    ```zsh
    ssh -i <PATH_TO_YOUR_PRIVATE_KEY> ubuntu@<YOUR_EC2_IP>
    
    (例) ssh -i ~/.ssh/my-key.pem ubuntu@192.0.2.1
    ```

1. isuconユーザーに切り替えます。
    ```bash
    sudo -i -u isucon
    ```

1. DBに初期データを投入します。
    ```bash
    ~/webapp/sql/init.sh
    ```

1. 初期状態ではGoによる実装が起動しているため、Pythonに切り替えます。
    ```bash
    sudo systemctl disable --now isupipe-go.service
    sudo systemctl enable --now isupipe-python.service
    ```

## アプリケーションにAPMを入れる

:::note info
APM（Application Performance Monitoring）とは、アプリケーションの動作状況をリアルタイムで監視・分析し、パフォーマンスの問題を特定して改善するためのツールです。
ISUCONにおける活用方法としては、アプリケーションの各部分（データベースクエリ、外部API呼び出し、特定の処理など）の実行時間を把握し、パフォーマンスのボトルネック特定に役立てることができます。
:::

公式ドキュメントの「[Install the Python agent](https://docs.newrelic.com/install/python/)」を参考に進めます。

1. `newrelic`パッケージをインストールします。ISUCON13のPython環境では`pipenv`が使われているようなので、`pipenv`を使ってインストールします。
    ```bash
    cd ~/webapp/python
    pipenv install newrelic
    ```

1. New Relicのライセンスキーを取得します。画面左下のユーザー設定から「**API Keys**」を選択し、「**Create a key**」から作成することができます。**Key type** には`Ingest - License`を選択します。
![スクリーンショット 2024-12-01 18.27.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/728092e0-a865-bb8f-1516-b65d09631a2d.png)

1. `newrelic.ini`ファイルを生成します。
    ```bash
    pipenv run newrelic-admin generate-config <YOUR_LICENSE_KEY> newrelic.ini
    ```

1. `newrelic.ini`ファイルの`app_name`を`isupipe`（ISUCON13の題材アプリケーションの名前）などわかりやすい名前に変更しておきます。
    ```diff
    - app_name = 
    + app_name = isupipe
    ```

1. `app.py`ファイルの一番上に以下のようにコードを追記し、エージェントパッケージのインポート、エージェントを初期化する呼び出しを行います。
    ```diff_python
    + import newrelic.agent
    + newrelic.agent.initialize("newrelic.ini")
    + 
    import hashlib
    ```



1. ベンチマーカーを動かし、New Relicにデータが送られていることを確認します。
    ```bash
    cd ~
    ./bench run --enable-ssl
    ```
    うまくいけば、以下のように「**APM & Services**」に先ほど`app_name`に設定した名前のエンティティが現れます。（数分かかります）
    ![スクリーンショット 2024-12-01 18.52.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/889b88e8-a4a9-21dc-d836-4bfd0f78b493.png)


## Infrastructure Agentを入れる
「[ISUCON 環境に New Relic Infrastructure Agent を入れてみる](https://newrelic.com/jp/blog/how-to-relic/isucon-infra)」を参考に進めます。
上記記事からの引用になりますが、こちらの手順を実施することで以下のような機能を利用することができます。
>  - ホスト単位あるいはプロセス単位でのCPU、メモリ、IOなどのメトリクス => Infrastructuer Agent
> - MySQLの秒間クエリ数、接続数、スロークエリ数やインベントリ情報 => MySQL Integration
> - ログの転送と分析 => Logs Integration　

1. 左のペインの「**Integrations & Agents**」から`Guided install`を選択します。

    ![infrastructure_agent_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/9569d4b4-3fac-73e1-bfc5-79bfb07e10f5.png)

2. 「**Auto-discovery**」から`Linux`を選択します。

    ![infrastructure_agent_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/677acaf2-3da4-c65f-d04e-4fafaf63e532.png)

3. User keyを新たに作成する場合は「**Create a new key**」でキーを生成、コピーしてから「**Continue**」で次に進みます。

    ![infrastructure_agent_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/76b2f547-9dab-a4d2-aa00-a411c6ad8d9a.png)


4. インストールするためのコマンドが表示されるので、「**Copy to clipboard**」でコピーします。

    ![infrastructure_agent_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/5f5fd1a6-887b-ac53-97d0-9bd668d23913.png)


5. EC2に接続されているターミナルに戻り、コピーしたコマンドをペーストして実行します。いくつかプロンプトに回答しインストールを進めます。
![スクリーンショット 2024-11-23 22.26.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/8f137961-cb30-5ab4-00ef-21a40823a233.png)


6. インストール完了後にNew Relicの画面に戻り「**Continue**
」で次に進むと、インストール状況を確認することができます。

    ![infrastructure_agent_5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/f4b3dabf-f562-f3b4-16ed-2d950ea166df.png)

以上で準備が整いました！
次章から実際にチューニングしてみましょう :rocket: 

# いざ、チューニング

まずはベンチを実行し、データを収集します。

実行が完了したら、「**All Entities**」から、先ほどの手順でInfrastructure Agentを入れたホストをクリックしてみます。

![スクリーンショット 2024-12-01 19.16.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/8ab76e25-b8b4-c041-622e-f8e19eccf871.png)

「**Processes**」を確認すると、ベンチマーカーのプロセスを除いて、`mysql`プロセスのCPU使用率が最も高くなっていることが分かりました。

![スクリーンショット 2024-12-01 19.17.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/a6a74835-43bd-ca61-1181-619f2e01747d.png)

続いて、「**APM & Services**」の「**Summary**」を見てみます。
トランザクションタイムの大部分をMySQLが占めていることが分かりました。
まずはMySQL周りのチューニングをしていくことになりそうです。
![スクリーンショット 2024-12-01 19.18.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/69c3a4a5-9ef2-6112-3c85-d381b55209f9.png)

続いて、「**Transactions**」を見ます。
「**Sort by**」 にはデフォルトで `Most time consuming`が指定されており、`__main__:search_livestreams_handler`のトランザクションが最も時間を消費していることが分かります。

ここで、`search_livestreams_handler`とは`/api/livestream/search`エンドポイントに対応したPythonの関数名で、`app.py`に書かれています。APMが勝手にエンドポイントごとにトランザクションタイムを集計してくれているのです。

![スクリーンショット 2024-12-01 19.19.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/e491bd79-85a2-26fa-acae-93860278b8f2.png)

`__main__:search_livestreams_handler`をクリックすると、このトランザクションについてのより詳細なデータを確認することができます。

画面を下にスクロールすると、「**Transaction traces**」の欄にサンプリングされたトランザクションが表示されており、これをクリックします。（サンプリングされたトランザクションがない場合は、何度かベンチを実行すれば出てくるかと思います）

![スクリーンショット 2024-12-01 19.20.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/9ec11822-4f5f-3a44-3a46-fc3d87287931.png)

サンプリングされたトランザクションについて、より細かいデータを見ることができます。
![スクリーンショット 2024-12-01 19.22.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/95dc94e3-dfa4-673b-fc7b-06e6e9e5d070.png)

「**Database queries**」を見ると一番上に表示されている以下のクエリにトータルで約10sかかっていることが分かりました。
```sql
SELECT * FROM livestream_tags WHERE livestream_id = %s
```
![スクリーンショット 2024-12-01 19.22.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/a91f0ebf-0103-c1f7-31ea-c239232a0daf.png)

ターミナルに戻り、先ほどのクエリで使用されていたテーブルのインデックスを調べると、プライマリキーのインデックスしかないことが判明しました。
```bash
sudo mysql isupipe  # MySQLのisupipeデータベースに入る
SHOW INDEX FROM livestream_tags;  # インデックス情報を調べる
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/e0b8143d-e039-b791-b4e7-06db9d82f7f7.png)

WHEREで絞り込みに使用されていたカラムにインデックスを張ります。
```bash
ALTER TABLE livestream_tags ADD INDEX idx_livestream_id (livestream_id);
```

再度ベンチを実行します。
トランザクションの詳細を見ると、先ほどは`10s`かかっていたクエリが`2772ms`に改善されました :rocket: 
![スクリーンショット 2024-12-01 21.01.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/a25b5518-9636-25f5-15b4-67495b2b5064.png)

詳細は割愛しますが、同じ調子で他のテーブルにもインデックスを張っていきます。（「[ISUCON13 問題の解説と講評](https://isucon.net/archives/58001272.html)」で追加するインデックスの例を見ることができます）

---

一通りインデックスを張り終えたところで、今度は **Transactions** の **Sort by** を`Slowest average response time`に指定してみます。
すると、初期化処理を除くと`moderate_handler`の平均レスポンスタイムが最も遅いことが分かりました。

![スクリーンショット 2024-12-02 23.39.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/e79409df-3deb-977b-f7ae-af31c50a87ce.png)

詳細を深ぼってみると、`livecomments`テーブルへのDELETEが、1トランザクションあたり平均`1.06k`回走っており、これが処理時間の大半を占めていることが分かります。

![スクリーンショット 2024-12-02 23.40.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/7a9acee9-4b52-3994-a36b-006bb25ed4c9.png)

該当のコードを見てみると、二重ループの中でクエリが実行されており、**N+1問題**が発生していました。

```python
@app.route("/api/livestream/<int:livestream_id>/moderate", methods=["POST"])
def moderate_handler(livestream_id: int) -> tuple[dict[str, Any], int]:
    # ...
    
        # NGワードにヒットする過去の投稿も全削除する
        for ngword in ngwords:
            sql = "SELECT * FROM livecomments"
            c.execute(sql)
            rows = c.fetchall()
            if rows is None:
                app.logger.warn("failed to get livecomments")
                raise HttpException(
                    "failed to get livecomments",
                    INTERNAL_SERVER_ERROR,
                )
            livecomments = [models.LiveCommentModel(**row) for row in rows]

            for livecomment in livecomments:
                # app.logger.info(f"delete: {livecomment}")
                sql = """
                    DELETE FROM livecomments
                    WHERE
                    id = %s AND
                    (SELECT COUNT(*)
                    FROM
                    (SELECT %s AS text) AS texts
                    INNER JOIN
                    (SELECT CONCAT('%%', %s, '%%')	AS pattern) AS patterns
                    ON texts.text LIKE patterns.pattern) >= 1;
                """
                c.execute(sql, [livecomment.id, livecomment.comment, ngword.word])
```

コードを修正してN+1問題を解消します。（参考までに、ChatGPTに書いてもらったコードを載せておきます）

<details><summary>修正後のコードを表示</summary>

```python
def moderate_handler(livestream_id: int) -> tuple[dict[str, Any], int]:
    verify_user_session()

    user_id = session.get(Settings.DEFAULT_USER_ID_KEY)
    if not user_id:
        raise HttpException("failed to find user-id from session", UNAUTHORIZED)

    req = get_request_json()
    if not req or "ng_word" not in req:
        raise HttpException(
            "failed to decode the request body as json",
            BAD_REQUEST,
        )

    conn = engine.raw_connection()

    try:
        conn.start_transaction()
        c = conn.cursor(dictionary=True)

        # 配信者自身の配信に対するmoderateなのかを検証
        sql = "SELECT * FROM livestreams WHERE id = %s AND user_id = %s"
        c.execute(sql, [livestream_id, user_id])
        owned_livestreams = c.fetchall()
        if owned_livestreams is None or len(owned_livestreams) == 0:
            raise HttpException(
                "A streamer can't moderate livestreams that other streamers own",
                BAD_REQUEST,
            )

        sql = "INSERT INTO ng_words(user_id, livestream_id, word, created_at) VALUES (%s, %s, %s, %s)"
        c.execute(
            sql,
            [
                user_id,
                livestream_id,
                req["ng_word"],
                datetime.now().timestamp(),
            ],
        )

        word_id = c.lastrowid

        # NGワードにヒットする過去の投稿を一度のクエリで取得
        sql = """
            SELECT lc.*
            FROM livecomments lc
            INNER JOIN ng_words nw
            ON lc.livestream_id = nw.livestream_id
            WHERE nw.livestream_id = %s AND lc.comment LIKE CONCAT('%%', nw.word, '%%')
        """
        c.execute(sql, [livestream_id])
        rows = c.fetchall()

        if rows is None:
            raise HttpException("failed to get livecomments", INTERNAL_SERVER_ERROR)
        
        livecomment_ids_to_delete = [row['id'] for row in rows]

        # 一括削除
        if livecomment_ids_to_delete:
            sql = "DELETE FROM livecomments WHERE id IN (%s)" % (
                ",".join(["%s"] * len(livecomment_ids_to_delete))
            )
            c.execute(sql, livecomment_ids_to_delete)

        return asdict(models.ModerateResponse(word_id=word_id)), CREATED
    except DatabaseError as err:
        conn.rollback()
        raise err
    finally:
        conn.commit()
        conn.close()

```

</details>

再度計測すると、先ほどまではトップだった`moderate_handler`がずいぶん下に来ており、N+1問題が解消できていることも確認できました :rocket: 

![スクリーンショット 2024-12-03 0.12.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/1a3eacdf-f921-9e21-9b86-a1047d24250e.png)

![スクリーンショット 2024-12-03 0.13.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3933957/a6c701b3-399d-4770-6f42-c45d4aaed94e.png)

New Relicを活用することで、ISUCONにおける代表的なチューニングであるインデックスの作成とN+1問題の解消を行うことができました。

改善できる余地はまだまだありそうですが、本記事でのチューニングは以上で終わりたいと思います。

# まとめ

いかがだったでしょうか？

アプリケーションに New Relic を統合することの容易さと、問題分析のためのパワフルなUIを体験していただけたのではないかと思います！

10分程度で完了できるインストール作業を行うだけで、直感的でイカしたUIを利用でき、アプリケーションの問題点を発見することができました。

しかし、本記事で触れることができた New Relic の機能はほんの一部です。 モバイル・ブラウザ等のフロントエンドの観測、アラート通知、脆弱性管理などなど、New Relic にはオブザーバビリティのための機能がまだまだたくさん搭載されています。

本記事を読んでご興味を持たれた方はぜひ New Relic を触ってみてください！

# 参考にさせていただいた記事

- [ISUCONの過去問にチャレンジするためのシンプルな環境構築](https://isucon.net/archives/54946542.html)
- [matsuu/aws-isucon: ISUCON過去問をAWS環境で構築するための一式](https://github.com/matsuu/aws-isucon)
- [ISUCON13 当日マニュアル](https://github.com/isucon/isucon13/blob/c52b359fc6e733e1193ac8e9835bea23856566e7/docs/cautionary_note.md)
- [ISUCON13 問題の解説と講評](https://isucon.net/archives/58001272.html)
- [New Relic フリープランで始めるオブザーバビリティ！ #初心者 - Qiita](https://qiita.com/ktst79/items/5ee74b34e6adb8a7a6fa)
- [Install the Python agent](https://docs.newrelic.com/install/python/)
- [ISUCON 環境に New Relic Infrastructure Agent を入れてみる](https://newrelic.com/jp/blog/how-to-relic/isucon-infra)


[^1]:「ISUCON」は、LINEヤフー株式会社の商標または登録商標です。：https://isucon.net