2026年7月23日

2026年7月23日

Pythonでログファイルを解析する方法|アクセスログとPHPエラーログを標準ライブラリで集計

Python でログファイルを解析するなら、正規表現で1行ずつ項目を取り出し、collections.Counter で数える形が基本です。追加のライブラリは要らず、gzip で圧縮された古いログもそのまま読めます。この記事では、Nginx・Apache の combined 形式のアクセスログから「ステータスコード別の件数」「時間帯別のアクセス数」「404 の多い URL」「ログインページへの POST が多い IP」を集計するスクリプトと、WordPress の debug.log(PHP エラーログ)を種類ごとにまとめる方法を説明します。読み取れない行の扱いや、大きなログでメモリを使い切らない書き方もあわせて扱います。

ログの形式を先に確認する

解析を書く前に、対象のログが実際にどんな形式なのかを確かめます。まずは数行を表示してみてください。

head -n 3 /var/log/nginx/access.log

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)を変えている場合は、項目の並びが違います。設定ファイルの定義と、実際の数行を見比べてから正規表現を書くのが近道です。

1行を項目に分解する正規表現

combined 形式を名前つきグループで分解します。名前を付けておくと、m["status"] のように項目名で値を取り出せます。

import re

LINE_RE = re.compile(
    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>[^"]*)"'
)

line = '203.0.113.5 - - [18/Oct/2026:09:30:12 +0900] "GET /blog/?p=12 HTTP/1.1" 200 5320 "-" "curl/8.5.0"'
m = LINE_RE.match(line)
print(m["ip"], m["status"], m["path"])  # 203.0.113.5 200 /blog/?p=12

この正規表現に合わない行は実際のログに少しずつ混ざります。代表的なのは次のようなものです。

  • 不正な形式のリクエスト(リクエスト行が "-" だけ、バイナリのごみが入っている など)。多くはステータス 400 の行です。
  • ユーザーエージェントに " が含まれる行。Apache は \" のようにエスケープするため、上の [^"]* では途中で切れてしまいます。
  • log_format を変えた後の行。

合わない行は捨てずに件数を数えておき、割合が多いときは数行を表示して原因を確認します。「合わない行が0件」を目指すより、「何件合わなかったかを把握している」ことのほうが大切です。

アクセスログを集計するスクリプト

ファイルを1行ずつ読むので、数 GB のログでもメモリをほとんど使いません。.gz で終わるファイルは gzip.open で開くため、ローテーションで圧縮された過去のログも同じように扱えます。

#!/usr/bin/env python3
"""combined 形式のアクセスログを集計する(.gz も可)"""
import argparse
import gzip
import re
from collections import Counter
from pathlib import Path

LINE_RE = re.compile(
    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>[^"]*)"'
)
LOGIN_PATHS = ("/wp-login.php", "/xmlrpc.php")


def open_log(path: Path):
    if path.suffix == ".gz":
        return gzip.open(path, "rt", encoding="utf-8", errors="replace")
    return path.open(encoding="utf-8", errors="replace")


def records(paths):
    for path in paths:
        with open_log(path) as f:
            for line in f:
                m = LINE_RE.match(line)
                yield m.groupdict() if m else None


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("logs", nargs="+", type=Path, help="古い順に指定する")
    ap.add_argument("--top", type=int, default=10)
    args = ap.parse_args()

    total = unmatched = 0
    status, hours, not_found, login_posts = Counter(), Counter(), Counter(), Counter()

    for rec in records(args.logs):
        total += 1
        if rec is None:
            unmatched += 1
            continue
        status[rec["status"]] += 1
        hours[rec["time"][:14]] += 1  # 例: 18/Oct/2026:09(時単位)
        path = rec["path"].split("?", 1)[0]  # クエリ文字列は除いて数える
        if rec["status"] == "404":
            not_found[path] += 1
        if rec["method"] == "POST" and path.startswith(LOGIN_PATHS):
            login_posts[rec["ip"]] += 1

    print(f"行数 {total} / 読み取れなかった行 {unmatched}")
    print("\n[ステータスコード別]")
    for code, n in sorted(status.items()):
        print(f"  {code}: {n}")
    print("\n[時間帯別]")
    for hour, n in hours.items():  # ログの並び順(古い順)で表示
        print(f"  {hour}時台: {n}")
    print(f"\n[404 の多いパス 上位{args.top}]")
    for p, n in not_found.most_common(args.top):
        print(f"  {n:6d}  {p}")
    print(f"\n[ログイン系への POST が多い IP 上位{args.top}]")
    for ip, n in login_posts.most_common(args.top):
        print(f"  {n:6d}  {ip}")


if __name__ == "__main__":
    main()
python analyze_access.py /var/log/nginx/access.log.2.gz /var/log/nginx/access.log.1 /var/log/nginx/access.log

読み方のポイントです。

  • ステータスコード別:5xx が増えている時間帯があれば、その時刻のエラーログを確認します。4xx の大半は 404 であることが多いです。
  • 時間帯別:日時の文字列の先頭14文字(日付と時)をそのままキーにしているので、日時の変換をせずに速く数えられます。ローテーション後のファイル名は数字が大きいほど古いので、表示を時系列にしたいときは古い順に並べて渡してください。
  • 404 の多いパス:削除したページへのリンクが残っていればリダイレクトや修正を検討します。存在しない .env や管理画面のパスを探すアクセスも多く、こちらは攻撃の下見なので、ページを作る必要はありません。
  • ログイン系への POST:/wp-login.php や /xmlrpc.php に大量の POST を送る IP は、パスワードの総当たりを試している可能性があります。ログイン試行回数の制限や、使っていなければ XML-RPC の無効化を検討する材料になります。

日時で絞り込む・応答時間を集計する

「昨日の 9時から 10時だけ」のように絞り込むには、日時の文字列を datetime に変換します。

from datetime import datetime

dt = datetime.strptime("18/Oct/2026:09:30:12 +0900", "%d/%b/%Y:%H:%M:%S %z")

%b(月の英語略称)はロケールの影響を受けます。Python は起動時に日時のロケールを C のままにするため通常は問題ありませんが、スクリプトの中で locale.setlocale() を呼んで日本語のロケールにしている場合は、Oct を読めずに失敗します。行数が多いと変換に時間がかかるので、絞り込みに使うときだけ変換するのが効率的です。

Nginx の log_format の末尾に $request_time(リクエストの処理時間)を加えているなら、正規表現の最後に (?: (?P<rt>\d+\.\d+))? を足して取り出せます。集めた値の中央値や 95 パーセンタイルは標準ライブラリで計算できます。

import statistics

times = [0.12, 0.08, 1.95, 0.20, 0.15]  # 実際には rec["rt"] を float にして集める
print(statistics.median(times))
print(statistics.quantiles(times, n=100, method="inclusive")[94])  # 95 パーセンタイル

quantiles() の既定の計算方法(exclusive)は、データが少ないと最大値より大きい値を返すことがあります。ログのように手元のデータそのものの分布を見たい場合は、method="inclusive" を指定すると実際の値の範囲に収まります。

平均だけを見ると、少数の極端に遅いリクエストに引きずられたり、逆に埋もれたりします。遅いページを探すときは、パスごとの中央値と上位のパーセンタイルを並べて見るのがおすすめです。

WordPress の debug.log を種類ごとにまとめる

WordPress で WP_DEBUG_LOG を有効にすると、wp-content/debug.log に PHP のエラーが記録されます。同じエラーが何千行も並ぶことが多いので、「どのファイルの何行目で、どの種類のエラーが何回出たか」にまとめると、直す順番が見えてきます。

import re
from collections import Counter

ENTRY_RE = re.compile(
    r'^\[(?P<time>[^\]]+)\] PHP (?P<level>[A-Za-z ]+?):\s+(?P<msg>.*?)'
    r'(?: in (?P<file>\S+?)(?: on line |:)(?P<line>\d+))?\s*$'
)

counts = Counter()
with open("wp-content/debug.log", encoding="utf-8", errors="replace") as f:
    for line in f:
        m = ENTRY_RE.match(line)
        if m:  # スタックトレースの続きの行(# から始まる行など)は数えない
            counts[(m["level"], m["file"], m["line"])] += 1

for (level, file, line_no), n in counts.most_common(15):
    print(f"{n:6d}  {level:15s} {file}:{line_no}")

Fatal error はサイトの停止につながるので最優先、Warning は動作に影響している可能性があるもの、Deprecated は将来の PHP で動かなくなる予告、という順で確認します。ファイルのパスにプラグインやテーマのディレクトリ名が含まれていれば、どの部品が原因かの見当が付きます。PHP のバージョンによってメッセージの書式が少し違うことがあるので、合わない行が多いときは実際の行を見て正規表現を調整してください。

debug.log は公開ディレクトリの中にできるため、作業が終わったら WP_DEBUG_LOG を無効に戻すか、Web から見えない場所にログを出す設定にしておきます。

解析結果とログの扱いに注意する

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

自作スクリプトは、決まった集計を定期的に出したり、特定の調査をしたりする用途に向いています。リアルタイムに眺めたいなら GoAccess、複数サーバーのログをまとめて検索したいなら Fluentd や Grafana Loki のようなログ基盤も選択肢になります。各ツールの機能や対応形式は更新により変わることがあります(2026年10月現在)。

まとめ

  • 先に実際のログの数行と log_format を確認し、名前つきグループの正規表現で項目を取り出す
  • ファイルは1行ずつ読み、.gz は gzip.open(..., "rt") で開く。読み取れなかった行は件数を数えて原因を確認する
  • Counter でステータスコード・時間帯・404 のパス・ログイン系への POST を集計する
  • 日時の変換は絞り込みに必要なときだけ。応答時間は中央値とパーセンタイルで見る
  • debug.log は種類・ファイル・行番号でまとめ、Fatal error から直す。ログの個人情報の扱いにも注意する

お気軽にご相談ください

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