2026年7月17日

2026年7月17日

PythonのPandasでログデータを分析する方法|アクセスログをDataFrameにして時間帯・ステータス・IPで集計

アクセスログを pandas で分析するには、ログの各行を Series.str.extract() と正規表現で列に分解して DataFrame にし、日時を datetime 型に変換してから resample()・groupby()・crosstab() で集計します。一度表の形にしてしまえば、「時間帯別のステータスコードの内訳」「転送量の多い URL」「5分間に何度もログインを試す IP」のような切り口を数行で切り替えられます。この記事では、読み込みと型の整え方、よく使う集計の書き方、数 GB のログをメモリに収める工夫、JSON 形式のログを読む方法、結果の保存までを説明します。

標準ライブラリだけで決まった集計を出すスクリプトとは違い、pandas は「いろいろな角度から何度も見直す」調査に向いています。Jupyter Notebook や対話モードで試しながら進める使い方を想定しています。

準備:pandas のインストールとログの形式の確認

仮想環境に pandas を入れます。

python3 -m venv .venv
. .venv/bin/activate
pip install pandas

次に、対象のログの形式を確認します。Nginx・Apache の既定でよく使われる combined 形式は、次のような1行です。

203.0.113.5 - - [18/Oct/2026:09:30:12 +0900] "GET /blog/?p=12 HTTP/1.1" 200 5320 "https://example.com/" "Mozilla/5.0 (...)"

左から、接続元 IP、識別子、ユーザー名、日時、リクエスト行、ステータスコード、送信バイト数、リファラー、ユーザーエージェントです。log_format(Nginx)や LogFormat(Apache)を変更しているサーバーでは並びが違うので、設定ファイルと実際の数行を見比べてから正規表現を合わせてください。

combined 形式は区切り文字が統一されていないため、pd.read_csv() でそのまま読むのは困難です。この記事では、行を文字列として読み込み、正規表現で列に分ける方法を使います。

ログを DataFrame に読み込んで型を整える

str.extract() に名前つきグループの正規表現を渡すと、グループ名がそのまま列名になります。

import pandas as pd

PATTERN = (
    r'^(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] '
    r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" '
    r'(?P<status>\d{3}) (?P<size>\d+|-) '
    r'"(?P<referer>[^"]*)" "(?P<ua>[^"]*)"'
)


def load(lines):
    raw = pd.Series(lines, dtype="string")
    df = raw.str.extract(PATTERN)
    bad = int(df["ip"].isna().sum())          # 正規表現に合わなかった行の数
    df = df.dropna(subset=["ip"]).copy()

    df["time"] = pd.to_datetime(
        df["time"], format="%d/%b/%Y:%H:%M:%S %z", utc=True
    ).dt.tz_convert("Asia/Tokyo")
    df["status"] = pd.to_numeric(df["status"]).astype("int16")
    df["size"] = pd.to_numeric(df["size"], errors="coerce").fillna(0).astype("int64")
    df["path_only"] = df["path"].str.split("?", n=1).str[0]   # クエリ文字列を除いたパス
    for col in ("method", "ip"):
        df[col] = df[col].astype("category")
    return df, bad


with open("access.log", encoding="utf-8", errors="replace") as f:
    df, bad = load(f.read().splitlines())

print(len(df), "行 / 読み取れなかった行", bad)
print(df.dtypes)

それぞれの処理の意図です。

  • 合わなかった行を数える:不正なリクエストやバイナリのごみなど、正規表現に合わない行は全列が欠損値になります。捨てる前に件数を出しておき、多いときは load() の中で dropna() の前に print(raw[df["ip"].isna()].head()) を入れるなどして元の行を確認し、正規表現を直します。
  • **日時は format を指定して変換する**:書式を指定しないと pandas が1行ずつ推測するため、遅くなるうえ、日と月の取り違えが起きることがあります。utc=True で一度 UTC にそろえてから日本時間に変換しておくと、夏時間のある国のサーバーや、途中でタイムゾーン設定を変えたログが混ざっても扱えます。
  • **転送量の - を 0 にする**:本文の無い応答(304 など)は送信バイト数が - と記録されます。errors="coerce" で数値にできない値を欠損値にしてから 0 で埋めます。
  • **category 型にする**:IP やメソッドのように同じ値が何度も出てくる列は、category 型にするとメモリ使用量が大きく減り、groupby() も速くなります。

よく使う集計の書き方

ステータスコード別・時間帯別の件数

# ステータスコード別の件数
print(df["status"].value_counts().sort_index())

# 1時間ごとのリクエスト数("h" は1時間、"5min" は5分)
hourly = df.set_index("time").resample("h").size()
print(hourly)

# 時間帯 × ステータスの種類(2xx/3xx/4xx/5xx)のクロス集計
table = pd.crosstab(df["time"].dt.floor("h"), df["status"] // 100 * 100)
print(table)

resample() は、アクセスが1件も無い時間帯も 0 として行を作ります。グラフにしたときに「アクセスが無かった時間」が抜け落ちないので、障害の時間帯を探すときに便利です。クロス集計で 5xx の列が増えている時間帯があれば、同じ時刻の PHP やデータベースのエラーログを確認します。

時間の単位を表す文字は pandas のバージョンで変わってきた経緯があり、古い記事で見かける大文字の "H" や "T" は、新しい版では非推奨またはエラーになります。"h"・"min" を使ってください。

404 の多い URL と転送量の多い URL

# 404 の多いパス 上位10件
print(df.loc[df["status"] == 404, "path_only"].value_counts().head(10))

# パスごとのリクエスト数と合計転送量(転送量の多い順)
by_path = (
    df.groupby("path_only")["size"]
      .agg(["count", "sum"])
      .sort_values("sum", ascending=False)
)
print(by_path.head(10))

転送量の多いパスが画像や動画なら、圧縮や配信方法を見直す候補になります。404 の上位に、削除した記事への古いリンクが並んでいればリダイレクトを検討し、.env や存在しない管理画面のパスが並んでいれば、それは攻撃の下見なので対応は不要です。

短時間にログインを繰り返す IP

login = df[
    (df["method"] == "POST")
    & df["path_only"].isin(["/wp-login.php", "/xmlrpc.php"])
]
per_5min = login.groupby(
    ["ip", pd.Grouper(key="time", freq="5min")], observed=True
).size()
print(per_5min[per_5min >= 20].sort_values(ascending=False))

IP と5分ごとの時間枠の組み合わせで数え、5分間に 20 回以上 POST している組み合わせを抜き出しています。observed=True は、カテゴリ型の列で実際には存在しない組み合わせまで 0 件として作らないための指定です。上位に並ぶ IP は総当たり攻撃の可能性があるので、ログイン試行回数の制限や、使っていない XML-RPC の無効化を検討する材料になります。20 回という基準は例なので、サイトの規模に合わせて調整してください。

応答時間を分析する

Nginx の log_format の末尾に $request_time を追加しているなら、正規表現の最後に (?: (?P<rt>\d+\.\d+))? を足して取り出し、pd.to_numeric() で数値にします。遅いページを探すときは、平均ではなく中央値と上位のパーセンタイルを並べます。

df["rt"] = pd.to_numeric(df["rt"], errors="coerce")
slow = df.groupby("path_only")["rt"].quantile([0.5, 0.95]).unstack()
print(slow.sort_values(0.95, ascending=False).head(10))

平均は、まれに発生する極端に遅いリクエストに引っ張られます。95 パーセンタイルが高いのに中央値は低いページは、「たまに非常に遅くなる」ページで、外部 API の呼び出しや重いクエリが疑われます。

大きなログを分割して処理する

f.read().splitlines() はファイル全体をメモリに読み込むため、数 GB のログではメモリが足りなくなります。その場合は、一定の行数ずつ DataFrame にして集計し、結果だけを足し合わせます。

import itertools

status_total = pd.Series(dtype="int64")
with open("access.log", encoding="utf-8", errors="replace") as f:
    while True:
        chunk = list(itertools.islice(f, 200_000))  # 20万行ずつ読む
        if not chunk:
            break
        part, _ = load([line.rstrip("\n") for line in chunk])
        status_total = status_total.add(part["status"].value_counts(), fill_value=0)

print(status_total.astype("int64").sort_index())

件数や合計は分割しても足し算で正しく求まりますが、中央値やパーセンタイルは分割したまま足し合わせられません。そういった集計が必要なら、先に必要な列だけに絞った DataFrame を作るか、期間を絞って読み込んでください。

一度整えた DataFrame は Parquet 形式で保存しておくと、次回からは正規表現での分解を飛ばして短時間で読み込めます。df.to_parquet("access.parquet") で保存し、pd.read_parquet() で読み込みます。Parquet の読み書きには pyarrow などの追加パッケージが必要です。

JSON 形式のログなら読み込みが簡単になる

ログを JSON 形式で出力するように設定しておくと、正規表現が要らなくなります。Nginx なら log_format に escape=json を指定して、1行に1つの JSON を書き出す形にできます。この形式は pd.read_json() に lines=True を付けて読み込めます。

df = pd.read_json("access.json.log", lines=True)

# 大きなファイルは chunksize を付けると、指定した行数ずつ DataFrame が返る
for part in pd.read_json("access.json.log", lines=True, chunksize=200_000):
    ...

項目の追加や順番の変更にも強く、ユーザーエージェントに " が含まれて正規表現が崩れる、といった問題も起きません。これから分析を続けるなら、ログの形式から見直す価値があります。

結果の保存と扱いの注意

集計結果は to_csv() で CSV にしておくと、表計算ソフトで共有できます。encoding="utf-8-sig" を付けると、Excel で開いたときに日本語が文字化けしにくくなります。matplotlib を入れておけば、hourly.plot() のように DataFrame や Series からそのままグラフを描けます。

アクセスログの IP アドレスやユーザーエージェント、URL のクエリ文字列は、個人に結びつく情報として扱うのが安全です。集計結果を共有するときは IP を伏せる、元のログや Parquet ファイルの保存期間を決めるなど、サイトのプライバシーポリシーと合わせて運用してください。

pandas は 3.0 で文字列の既定の型やコピーの扱いなどが変わりました。この記事のコードは 2 系の新しい版と 3 系の両方で動く書き方にしていますが、古い記事のコードを使うときは警告が出ていないか確認してください(2026年10月現在)。

まとめ

  • combined 形式のログは、行を文字列で読み込み、名前つきグループの正規表現と str.extract() で列に分解する
  • 日時は format と utc=True を指定して変換し、IP やメソッドは category 型にしてメモリを節約する
  • resample() で時間帯別、crosstab() で時間帯 × ステータス、pd.Grouper で「IP × 5分」のような集計ができる
  • 大きなログは行数を区切って集計結果を足し合わせ、整えたデータは Parquet で保存して再利用する
  • JSON 形式のログなら pd.read_json(lines=True) で読めるので、ログの形式を見直すのも有効

お気軽にご相談ください

お見積りへ お問い合わせへ
▲