Webサーバーのアクセスログをスクリプトで解析してみた(続き)

## はじめに
先日、Apacheのアクセスログを解析し、サーバー処理時間といったパフォーマンス情報やネットワーク帯域幅見積もりに必要な情報を出力するスクリプトを作成しました。

https://qiita.com/tbtakhk/items/63e022861aa969ea3e69

これに加えて、HTTPステータスが200以外のメソッド、URIなどを出力するスクリプト、サーバー処理時間が3秒以上のリクエストのメソッド、URIなどを出力するスクリプトを作成する事にしました。

前回作成した(ベースは生成AIに作成してもらった)スクリプトを参考にし、実装は比較的すぐにできたのですが、ログファイルの数が多かったり、出力するデータが多くなったりすると、スクリプトを実行しても結果が中々返ってこない事が分かりました。

本記事では、生成AIを活用してちょっとしたパフォーマンス改善を行った内容について、記述してみます。

:::note info
利用した生成AIは、Chat GPTのような公開されたものではなく、弊社内のみで利用可能なクローズドな生成AI(Open AIベース)です。
:::

## 修正前のスクリプトと生成AIとのやりとり
スクリプトの前提条件は、[この内容](https://qiita.com/tbtakhk/items/63e022861aa969ea3e69#%E5%89%8D%E6%8F%90%E6%9D%A1%E4%BB%B6)と同じです。

まず、修正前の「HTTPステータスが200以外のメソッド、URIなどを出力するスクリプト」です。

<details open><summary>HTTPステータスが200以外のメソッド、URIなどを出力するスクリプト</summary>

```python
import os
import glob
import pandas as pd

log_directory_output_filesを指定するCSVファイルパスを変更してください
config_file = "C:/performance/scripts/config/http_status_not_eq200_requests_config.csv"

アクセスログフォーマットの列名
columns = ["host_ip_address", "remote_hostname", "date", "time", "method_uri",
"http_status", "transfer(bytes)", "res_time", "JSESSIONID"]

解析結果を格納するDataFrameを作成
data = []

config_fileから組み合わせを取得
log_directory_output_files = pd.read_csv(config_file)

各組み合わせごとに解析を実行
for _, row in log_directory_output_files.iterrows():
	log_directory = row["Log Directory"]
	output_file = row["Output File"]
	print(log_directory)

	# ディレクトリ内のアクセスログファイルを取得
	log_files = glob.glob(os.path.join(log_directory, "**", "access_*.log"), recursive=True)

	# 各ログファイルを処理
	for log_file in log_files:
	    # ログファイルのパスから日付を取得
	    date_str = os.path.basename(log_file).split(".")[0].split("_")[-1]
	    print(date_str)
	    date = pd.to_datetime(date_str).strftime("%Y%m%d")
	    # アクセスログデータを読み込み
	    df = pd.read_csv(log_file, names=columns, header=None)
	    # "-"をNaNに変換
	    df["http_status"] = pd.to_numeric(df["http_status"], errors="coerce")
	    # 200以外のHTTPステータスのみをフィルタリング
	    filtered_df = df[df["http_status"] != 200]
	    # 解析結果をリストに追加
	    for _, row in filtered_df.iterrows():
	         data.append([log_directory, output_file, date, row["http_status"], row["method_uri"]])

解析結果をDataFrameに変換
result_df = pd.DataFrame(data, columns=["Log Directory", "Output File", "Date", "HTTP STATUS", "Method URI"])

日付とHTTPステータスを基準に昇順および降順ソート
result_df = result_df.sort_values(["Date", "HTTP STATUS"], ascending=[True, False])

解析結果をCSVファイルに出力
for _, row in result_df.iterrows():
output_file = os.path.abspath(row["Output File"])
filtered_df = result_df.loc[result_df["Output File"] == row["Output File"]]
filtered_df.to_csv(output_file, index=False)
```
</details>

生成AIに上記スクリプトの記述と下記の質問をしてみました。
```
(私)
上記スクリプトを実行した所、解析するログファイル数、ログファイルの行数が増えると結果がなかなか
返ってきません。
パフォーマンス観点の問題点と修正案を教えてください。
```
すると、以下のような回答が返ってきました。
```
(生成AIからの回答)
このスクリプトでは、ログファイルの数が増えると処理時間が長くなる可能性があります。
そのため、以下のパフォーマンスの問題が考えられます。

アクセスログデータをすべてメモリに読み込んでからフィルタリングするため、大量のログデータがある場合
にはメモリの使用量が増え、処理速度が低下する可能性があります。
ログファイルごとにDataFrameに追加していく際にループを回しているため、マルチスレッド処理ができず、
ログファイルが複数ある場合に処理速度が遅くなる可能性があります。
以上の問題点を解決するためには、以下の修正案を検討することができます。

メモリ使用量の削減: ログファイルを逐次読み込みながら、必要なデータのみを一時的に保持するようにする。
例えば、フィルタリングを行う前に不要な列を除外し、メモリ使用量を削減することができます。

マルチスレッド処理の導入: ログファイルごとに独立したスレッドで処理を行うことで、並列処理を実現し、
処理速度を向上させることができます。

ログデータの部分的な処理: ログファイルごとに一時的な結果を保存しておき、処理の最後で結果をまとめて
ソートしてCSVに出力するようにすることで、毎回結果をソートする必要がなくなります。

これらの修正を組み合わせることで、スクリプトのパフォーマンスを改善することができます。
また、処理対象のログデータの量や処理の要件に合わせて最適なアプローチを選択することも重要です。
```
なるほど。回答内容については理解できるのですが、Pythonの実装経験が無いため、どう実装したらよいのか分かりません。
そこで以下のように雑なお願いをしてみました。
```
(私)
この修正案を適用したスクリプトを作ってください。
```
すると、すぐに以下の修正案を提示してくれました。
```python
(生成AI)
import os
import glob
import pandas as pd
from concurrent.futures import ThreadPoolExecutor

# log_directory_output_filesを指定するCSVファイルパスを変更してください
config_file = "C:/performance/scripts/config/http_status_not_eq200_requests_config.csv"

# アクセスログフォーマットの列名
columns = ["host_ip_address", "remote_hostname", "date", "time", "method_uri",
           "http_status", "transfer(bytes)", "res_time", "JSESSIONID"]

# 解析結果を格納するDataFrameを作成
data = []

# config_fileから組み合わせを取得
log_directory_output_files = pd.read_csv(config_file)

# 各組み合わせごとに解析を実行
for _, row in log_directory_output_files.iterrows():
    log_directory = row["Log Directory"]
    output_file = row["Output File"]
    print(log_directory)

    # ディレクトリ内のアクセスログファイルを取得
    log_files = glob.glob(os.path.join(log_directory, "**", "access_*.log"), recursive=True)

    # ログファイルの一時的な結果を格納するリスト
    file_data = []

    # 各ログファイルを並列実行で処理
    with ThreadPoolExecutor() as executor:
        # ログファイルごとに処理を実行する関数
        def process_log_file(log_file):
            # ログファイルのパスから日付を取得
            date_str = os.path.basename(log_file).split(".")[0].split("_")[-1]
            print(date_str)
            date = pd.to_datetime(date_str).strftime("%Y%m%d")
            # アクセスログデータを読み込み
            df = pd.read_csv(log_file, names=columns, header=None)
            # "-"をNaNに変換
            df["http_status"] = pd.to_numeric(df["http_status"], errors="coerce")
            # 200以外のHTTPステータスのみをフィルタリング
            filtered_df = df[df["http_status"] != 200]
            # 解析結果をリストに追加
            for _, row in filtered_df.iterrows():
                file_data.append([log_directory, output_file, date, row["http_status"], row["method_uri"]])

        # 各ログファイルを並列実行
        executor.map(process_log_file, log_files)

    # ログファイルごとの一時的な結果を解析結果に追加
    data.extend(file_data)

# 解析結果をDataFrameに変換
result_df = pd.DataFrame(data, columns=["Log Directory", "Output File", "Date", "HTTP STATUS", "Method URI"])

# 日付とHTTPステータスを基準に昇順および降順ソート
result_df = result_df.sort_values(["Date", "HTTP STATUS"], ascending=[True, False])

# 解析結果をCSVファイルに出力
for _, row in result_df.iterrows():
    output_file = os.path.abspath(row["Output File"])
    filtered_df = result_df.loc[result_df["Output File"] == row["Output File"]]
    filtered_df.to_csv(output_file, index=False)
```
```
修正後のスクリプトでは、修正案で示した内容を反映しています。具体的には、以下の点が修正されています。

ログファイルの処理をマルチスレッドで並列実行することで、処理速度が向上しました。
一時的な結果を取り扱うためにリストを使用しており、各ログファイルごとの結果を後からまとめて解析結果に
追加しています。
concurrent.futures.ThreadPoolExecutorを使用してマルチスレッド処理を行い、executor.map()を
使用して各ログファイルを並列実行しています。
```
Pythonだと、こんな感じでマルチスレッド処理が実装できるのかと感心しつつ(古いJavaのバージョンでの実装経験しか無かったため、正直驚きました)、実際に実行してみると、解析処理は速くなりましたが、解析結果をCSVファイル出力するのに時間がかかったため、追加で以下をお願いしてみました。
```
(私)
以下で解析結果をCSVファイルに出力するのに時間がかかります。
ログファイル数、各ログファイルの行数が多い場合に速度改善する方法を教えてください。
```
```python
# 解析結果をCSVファイルに出力
for _, row in result_df.iterrows():
output_file = os.path.abspath(row["Output File"])
filtered_df = result_df.loc[result_df["Output File"] == row["Output File"]]
filtered_df.to_csv(output_file, index=False)
```
お願いばかりしていますが、すぐに回答が返ってきました。
```
(生成AI)
解析結果をCSVファイルに出力する部分でパフォーマンスが低下している可能性があります。
以下の方法を試してみることで速度改善が期待できます。

複数のログファイル結果をまとめて書き込む: 現在のコードでは、ログファイルごとにループを回して個別に
CSVファイルに書き込んでいます。ログファイルの数が多い場合には、ファイルI/Oのオーバーヘッドが大きく
なります。そのため、一度にまとめて解析結果をCSVファイルに書き込む方法を検討してみてください。

メモリへの書き込みを削減する: 現在のコードでは、すべての解析結果を一度にDataFrameに格納してから
CSVファイルに書き込んでいます。解析結果が大きい場合には、メモリ使用量が増えパフォーマンスが低下する
可能性があります。代わりに、結果を一時ファイルに書き込み、その後でまとめてCSVファイルにマージする
方法を検討してみてください。

以下に、上記の方法を組み込んだコードの例を示します。
```

<details open><summary>修正後のスクリプト(最終版)</summary>

```python
import os
import glob
import pandas as pd
from concurrent.futures import ThreadPoolExecutor

# log_directory_output_filesを指定するCSVファイルパスを変更してください
config_file = "C:/performance/scripts/config/http_status_not_eq200_requests_config.csv"

# アクセスログフォーマットの列名
columns = ["host_ip_address", "remote_hostname", "date", "time", "method_uri",
           "http_status", "transfer(bytes)", "res_time", "JSESSIONID"]

# 解析結果を一時的に格納するDataFrameを作成
result_df = pd.DataFrame(columns=["Log Directory", "Output File", "Date", "HTTP STATUS", "Method URI"])

# config_fileから組み合わせを取得
log_directory_output_files = pd.read_csv(config_file)

# 各組み合わせごとに解析を実行
for _, row in log_directory_output_files.iterrows():
    log_directory = row["Log Directory"]
    output_file = row["Output File"]
    print(log_directory)

    # ディレクトリ内のアクセスログファイルを取得
    log_files = glob.glob(os.path.join(log_directory, "**", "access_*.log"), recursive=True)

    # ログファイルの一時的な結果を格納するリスト
    file_data = []

    # 各ログファイルを並列実行で処理
    with ThreadPoolExecutor() as executor:
        # ログファイルごとに処理を実行する関数
        def process_log_file(log_file):
            # ログファイルのパスから日付を取得
            date_str = os.path.basename(log_file).split(".")[0].split("_")[-1]
            print(date_str)
            date = pd.to_datetime(date_str).strftime("%Y%m%d")
            # アクセスログデータを読み込み
            df = pd.read_csv(log_file, names=columns, header=None)
            # "-"をNaNに変換
            df["http_status"] = pd.to_numeric(df["http_status"], errors="coerce")
            # 200以外のHTTPステータスのみをフィルタリング
            filtered_df = df[df["http_status"] != 200]
            # 解析結果をリストに追加
            for _, row in filtered_df.iterrows():
                file_data.append([log_directory, output_file, date, row["http_status"], row["method_uri"]])

        # 各ログファイルを並列実行
        executor.map(process_log_file, log_files)

    # ログファイルごとの一時的な結果をDataFrameに追加
    file_df = pd.DataFrame(file_data, columns=["Log Directory", "Output File", "Date", "HTTP STATUS", "Method URI"])
    result_df = pd.concat([result_df, file_df], ignore_index=True)

# 日付とHTTPステータスを基準に昇順および降順ソート
result_df = result_df.sort_values(["Date", "HTTP STATUS"], ascending=[True, False])

# 解析結果をCSVファイルに出力
output_files = result_df["Output File"].unique()
for output_file in output_files:
    filtered_df = result_df[result_df["Output File"] == output_file]
    filtered_df.to_csv(output_file, index=False)
```
</details>

上記コードを実行した所、一番最初の実装に比べて爆速になったのでお礼を伝えます。
```
(私)
ありがとう！爆速になりました。

(生成AI)
よかったです！パフォーマンスの向上が見られて良かったです。修正案が効果を発揮したようで嬉しいです。

もし他にも何か質問や問題があれば、遠慮せずにお聞きください。お手伝いできることがありましたら、
喜んでお手伝いいたします。
```
生成AIに感情はないよなと思いつつ、喜んでくれている風で、こちらも嬉しくなりました。

それにしても、生成AIをうまく活用すると、生産性の向上に大きくつながる事が改めて実感できました。

上記の修正内容を参考に、サーバー処理時間が3秒以上のリクエストのメソッド、URIなどを出力するスクリプトを以下のように作成しました。

<details open><summary>サーバー処理時間が3秒以上のリクエストのメソッド、URIなどを出力するスクリプト</summary>

```python
import os
import glob
import pandas as pd
from concurrent.futures import ThreadPoolExecutor

# log_directory_output_filesを指定するCSVファイルパスを変更してください
config_file = "C:/performance/scripts/config/output_slow_req_over_3sec_config.csv"

# アクセスログフォーマットの列名
columns = ["host_ip_address", "remote_hostname", "date", "time", "method_uri",
           "http_status", "transfer(bytes)", "res_time", "JSESSIONID"]

# 解析結果を一時的に格納するDataFrameを作成
result_df = pd.DataFrame(columns=["Log Directory", "Output File", "Date", "Time", "HTTP STATUS", "Response Time(Sec)", "Transfer(bytes)", "Method URI"])

# config_fileから組み合わせを取得
log_directory_output_files = pd.read_csv(config_file)

# 各組み合わせごとに解析を実行
for _, row in log_directory_output_files.iterrows():
    log_directory = row["Log Directory"]
    output_file = row["Output File"]
    print(log_directory)

    # ディレクトリ内のアクセスログファイルを取得
    log_files = glob.glob(os.path.join(log_directory, "**", "access_*.log"), recursive=True)

    # ログファイルの一時的な結果を格納するリスト
    file_data = []

    # 各ログファイルを並列実行で処理
    with ThreadPoolExecutor() as executor:
        # ログファイルごとに処理を実行する関数
        def process_log_file(log_file):
            # ログファイルのパスから日付を取得
            date_str = os.path.basename(log_file).split(".")[0].split("_")[-1]
            print(date_str)
            date = pd.to_datetime(date_str).strftime("%Y%m%d")
            # アクセスログデータを読み込み
            df = pd.read_csv(log_file, names=columns, header=None)
            # "-"をNaNに変換
            df["res_time"] = pd.to_numeric(df["res_time"], errors="coerce")
            # 200以外のHTTPステータスのみをフィルタリング
            filtered_df = df[df["res_time"] >= 3]
            # 解析結果をリストに追加
            for _, row in filtered_df.iterrows():
                file_data.append([log_directory, output_file, date, row["time"], row["http_status"], row["res_time"], row["transfer(bytes)"], row["method_uri"]])

        # 各ログファイルを並列実行
        executor.map(process_log_file, log_files)

    # ログファイルごとの一時的な結果をDataFrameに追加
    file_df = pd.DataFrame(file_data, columns=["Log Directory", "Output File", "Date", "Time", "HTTP STATUS", "Response Time(Sec)", "Transfer(bytes)", "Method URI"])
    result_df = pd.concat([result_df, file_df], ignore_index=True)

# サーバー処理時間を基準に昇順ソート
result_df = result_df.sort_values("Response Time(Sec)", ascending=False)

# 解析結果を各出力ファイルに出力
output_files = result_df["Output File"].unique()
for output_file in output_files:
    print(output_file)
    filtered_df = result_df[result_df["Output File"] == output_file]
    filtered_df.to_csv(output_file, index=False)
```
</details>

Apacheのアクセスログで解析したい内容は、これで大体スクリプト化できたので、次はStatspackレポートを解析するスクリプトを作ってみたいと思います。

## おわりに
生成AIは間違った回答をする場合もありますし、クセのようなものもあり、質問の仕方を含め有効活用するにはそれなりの工夫が必要です。

しかし、ある程度の開発経験があれば、ちょっとした改善ツール・スクリプトを作成して、大幅な業務効率化を図る事ができます。

10年以上まともにコードを書いておらず、Pythonの構文も知らなかった人間が、生成AIの力を借りる事でここまでできるのですから、有効活用しない手はないなと改めて感じました。