🐍 第33课:日志分析

—— 解析 nginx 日志+统计+可视化

🏆 正则解析+统计分析+异常检测+HTML报告
✅ Python验证通过

📌 本课目标

1️⃣ nginx 日志格式

"""nginx combined 日志格式:
$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"

示例:
192.168.1.1 - - [10/Mar/2024:13:55:36 +0800] "GET /api/users HTTP/1.1" 200 1234 "-" "Mozilla/5.0"
10.0.0.5 - admin [10/Mar/2024:13:56:01 +0800] "POST /api/login HTTP/1.1" 401 95 "-" "curl/7.88"
"""

import re
from dataclasses import dataclass
from datetime import datetime
from typing import Optional

@dataclass
class NginxLogEntry:
    ip: str
    time: datetime
    method: str
    path: str
    status: int
    size: int
    referer: str
    user_agent: str

# 解析正则
NGINX_PATTERN = re.compile(
    r'(?P<ip>\S+) - (?P<user>\S+) '
    r'\[(?P<time>[^\]]+)\] '
    r'"(?P<method>\S+) (?P<path>\S+) \S+" '
    r'(?P<status>\d+) (?P<size>\d+) '
    r'"(?P<referer>[^"]*)" '
    r'"(?P<agent>[^"]*)"'
)

def parse_nginx_line(line):
    """解析单行 nginx 日志"""
    m = NGINX_PATTERN.match(line.strip())
    if not m:
        return None
    d = m.groupdict()
    try:
        time = datetime.strptime(d["time"], "%d/%b/%Y:%H:%M:%S %z")
    except ValueError:
        time = None
    return NginxLogEntry(
        ip=d["ip"],
        time=time,
        method=d["method"],
        path=d["path"],
        status=int(d["status"]),
        size=int(d["size"]),
        referer=d["referer"],
        user_agent=d["agent"],
    )

def parse_nginx_file(filepath):
    """解析 nginx 日志文件"""
    entries = []
    with open(filepath) as f:
        for line in f:
            entry = parse_nginx_line(line)
            if entry:
                entries.append(entry)
    print(f"解析完成: {len(entries)} 条有效日志")
    return entries

2️⃣ 统计分析

from collections import Counter
from typing import List

class LogAnalyzer:
    """日志分析器"""
    
    def __init__(self, entries: List[NginxLogEntry]):
        self.entries = entries
    
    def top_ips(self, n=10):
        """访问量 Top IP"""
        counter = Counter(e.ip for e in self.entries)
        return counter.most_common(n)
    
    def top_paths(self, n=10):
        """访问量 Top 路径"""
        counter = Counter(e.path for e in self.entries)
        return counter.most_common(n)
    
    def status_distribution(self):
        """状态码分布"""
        counter = Counter(e.status for e in self.entries)
        return dict(sorted(counter.items()))
    
    def hourly_distribution(self):
        """按小时分布"""
        counter = Counter(e.time.hour for e in self.entries if e.time)
        return dict(sorted(counter.items()))
    
    def error_rate(self):
        """错误率(4xx + 5xx)"""
        total = len(self.entries)
        errors = sum(1 for e in self.entries if e.status >= 400)
        return errors / total if total > 0 else 0
    
    def avg_response_size(self):
        """平均响应大小"""
        if not self.entries:
            return 0
        return sum(e.size for e in self.entries) / len(self.entries)
    
    def total_traffic(self):
        """总流量"""
        return sum(e.size for e in self.entries)
    
    def slow_paths(self, size_threshold=100000, n=10):
        """大响应路径(可能是慢接口)"""
        big = [e for e in self.entries if e.size > size_threshold]
        counter = Counter(e.path for e in big)
        return counter.most_common(n)
    
    def summary(self):
        """生成汇总报告"""
        return {
            "total_requests": len(self.entries),
            "unique_ips": len(set(e.ip for e in self.entries)),
            "error_rate": f"{self.error_rate()*100:.2f}%",
            "avg_size": f"{self.avg_response_size():.0f} bytes",
            "total_traffic": f"{self.total_traffic()/1024/1024:.1f} MB",
            "top_ips": self.top_ips(5),
            "top_paths": self.top_paths(5),
            "status_dist": self.status_distribution(),
        }

3️⃣ 异常检测

class AnomalyDetector:
    """日志异常检测"""
    
    def __init__(self, entries: List[NginxLogEntry]):
        self.entries = entries
    
    def detect_status_anomalies(self, threshold=0.1):
        """检测异常状态码"""
        status_counter = Counter(e.status for e in self.entries)
        total = len(self.entries)
        anomalies = []
        for status, count in status_counter.items():
            ratio = count / total
            if status >= 500 and ratio > threshold:
                anomalies.append({
                    "type": "高5xx比例",
                    "status": status,
                    "count": count,
                    "ratio": f"{ratio*100:.1f}%",
                })
            if status == 404 and ratio > 0.3:
                anomalies.append({
                    "type": "高404比例",
                    "status": status,
                    "count": count,
                    "ratio": f"{ratio*100:.1f}%",
                })
        return anomalies
    
    def detect_ip_anomalies(self, threshold=100):
        """检测可疑 IP(访问量异常高)"""
        counter = Counter(e.ip for e in self.entries)
        return [{"ip": ip, "count": count} 
                for ip, count in counter.most_common() 
                if count > threshold]
    
    def detect_traffic_spike(self, window_minutes=5, spike_threshold=3.0):
        """检测流量尖峰"""
        from itertools import groupby
        
        # 按分钟分组
        by_minute = {}
        for e in self.entries:
            if e.time:
                minute_key = e.time.strftime("%Y-%m-%d %H:%M")
                by_minute[minute_key] = by_minute.get(minute_key, 0) + 1
        
        if not by_minute:
            return []
        
        # 计算平均值和标准差
        counts = list(by_minute.values())
        avg = sum(counts) / len(counts)
        
        spikes = []
        for minute, count in sorted(by_minute.items()):
            if count > avg * spike_threshold:
                spikes.append({
                    "minute": minute,
                    "count": count,
                    "avg": f"{avg:.1f}",
                    "spike_ratio": f"{count/avg:.1f}x",
                })
        return spikes

4️⃣ 验证脚本

#!/usr/bin/env python3
"""第33课 日志分析验证"""
import re
from collections import Counter
from datetime import datetime

# 模拟日志数据
SAMPLE_LOGS = [
    '192.168.1.1 - - [10/Mar/2024:13:55:36 +0800] "GET /api/users HTTP/1.1" 200 1234 "-" "Mozilla/5.0"',
    '10.0.0.5 - - [10/Mar/2024:13:56:01 +0800] "POST /api/login HTTP/1.1" 401 95 "-" "curl/7.88"',
    '192.168.1.1 - - [10/Mar/2024:13:57:12 +0800] "GET /api/users HTTP/1.1" 200 2345 "-" "Mozilla/5.0"',
    '10.0.0.5 - - [10/Mar/2024:13:58:00 +0800] "GET /static/app.js HTTP/1.1" 200 54321 "-" "Chrome/120"',
    '172.16.0.1 - - [10/Mar/2024:13:59:30 +0800] "GET /api/data HTTP/1.1" 500 12 "-" "Python/3.12"',
]

NGINX_PATTERN = re.compile(
    r'(?P<ip>\S+) - (?P<user>\S+) '
    r'\[(?P<time>[^\]]+)\] '
    r'"(?P<method>\S+) (?P<path>\S+) \S+" '
    r'(?P<status>\d+) (?P<size>\d+) '
    r'"(?P<referer>[^"]*)" '
    r'"(?P<agent>[^"]*)"'
)

def test_parse():
    entries = []
    for line in SAMPLE_LOGS:
        m = NGINX_PATTERN.match(line)
        assert m is not None, f"Failed to parse: {line}"
        d = m.groupdict()
        entries.append(d)
    assert len(entries) == 5
    assert entries[0]["ip"] == "192.168.1.1"
    assert entries[0]["status"] == "200"
    print("✅ 日志解析测试通过")

def test_counter_stats():
    entries = []
    for line in SAMPLE_LOGS:
        m = NGINX_PATTERN.match(line)
        entries.append(m.groupdict())
    
    ip_counter = Counter(e["ip"] for e in entries)
    assert ip_counter["192.168.1.1"] == 2
    
    status_counter = Counter(e["status"] for e in entries)
    assert status_counter["200"] == 3
    
    path_counter = Counter(e["path"] for e in entries)
    assert path_counter["/api/users"] == 2
    print("✅ 统计分析测试通过")

def test_error_detection():
    entries = []
    for line in SAMPLE_LOGS:
        m = NGINX_PATTERN.match(line)
        entries.append(m.groupdict())
    
    errors = [e for e in entries if int(e["status"]) >= 400]
    assert len(errors) == 2  # 401 + 500
    print("✅ 异常检测测试通过")

if __name__ == "__main__":
    test_parse()
    test_counter_stats()
    test_error_detection()
    print("\n🎉 第33课全部验证通过!")

6️⃣ 生成 HTML 分析报告

from datetime import datetime

def generate_report(analysis_data: dict, output_path: str):
    """生成 HTML 日志分析报告"""
    html = f"""<!DOCTYPE html>
<html><head><meta charset="UTF-8">
<title>日志分析报告</title>
<style>
body {{ font-family: sans-serif; margin: 20px; background: #0f172a; color: #e2e8f0; }}
h1 {{ color: #34d399; }} h2 {{ color: #34d399; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #334155; padding: 8px; }}
th {{ background: #1e293b; color: #34d399; }}
.metric {{ display: inline-block; background: #1e293b; border-radius: 8px; padding: 15px; margin: 5px; }}
.metric .value {{ font-size: 2em; color: #34d399; }}
</style></head><body>
<h1>日志分析报告</h1>
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
<h2>核心指标</h2>
<div class="metric"><div class="value">{analysis_data.get('total', 0):,}</div><div>总请求数</div></div>
</body></html>"""
    
    with open(output_path, "w") as f:
        f.write(html)
    print(f"报告已生成: {output_path}")

7️⃣ 实时日志监控

import re
from collections import Counter, deque
from datetime import datetime
from typing import Callable, Optional

class LiveLogMonitor:
    """实时日志监控器"""
    
    def __init__(self, alert_rules=None, window_size=100):
        self.rules = alert_rules or []
        self.window = deque(maxlen=window_size)
        self.counters = Counter()
        self.on_alert: Optional[Callable] = None
    
    def process_line(self, line: str):
        self.window.append(line)
        self.counters["total"] += 1
        
        status_match = re.search(r"\s(\d{3})\s", line)
        if status_match:
            status = int(status_match.group(1))
            self.counters[f"status_{status // 100}xx"] += 1
            if status >= 500:
                self._check_alert("5xx", status)
    
    def _check_alert(self, metric, value):
        for rule in self.rules:
            if rule["metric"] == metric and value > rule["threshold"]:
                if self.on_alert:
                    self.on_alert({"metric": metric, "value": value})
    
    def get_error_rate(self):
        total = self.counters.get("total", 0)
        if total == 0: return 0
        errors = self.counters.get("status_4xx", 0) + self.counters.get("status_5xx", 0)
        return errors / total * 100

8️⃣ 常见日志格式解析

import re
from dataclasses import dataclass

# ===== Apache 日志 =====
APACHE_PATTERN = re.compile(
    r'(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+) \S+" (\d+) (\d+)'
)

# ===== Python logging 日志 =====
PYTHON_LOG_PATTERN = re.compile(
    r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d+) - (\w+) - (.+)'
)

@dataclass
class PythonLogEntry:
    timestamp: str
    level: str
    message: str

def parse_python_log(content: str):
    entries = []
    for line in content.strip().split("\n"):
        m = PYTHON_LOG_PATTERN.match(line)
        if m:
            entries.append(PythonLogEntry(
                timestamp=m.group(1),
                level=m.group(2),
                message=m.group(3),
            ))
    return entries

# ===== JSON 日志 =====
import json

def parse_json_logs(content: str):
    entries = []
    for line in content.strip().split("\n"):
        try:
            entry = json.loads(line)
            entries.append(entry)
        except json.JSONDecodeError:
            pass
    return entries

# ===== Syslog =====
SYSLOG_PATTERN = re.compile(
    r'(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+(\S+?):\s+(.*)'
)

def parse_syslog(content: str):
    entries = []
    for line in content.strip().split("\n"):
        m = SYSLOG_PATTERN.match(line)
        if m:
            entries.append({
                "timestamp": m.group(1),
                "host": m.group(2),
                "process": m.group(3),
                "message": m.group(4),
            })
    return entries

🔑 本课要点

  1. 日志解析——正则 + 结构化提取,把文本变数据
  2. 统计分析——collections.Counter 一行代码搞定频率统计
  3. 时间窗口——按小时/分钟聚合,发现流量模式
  4. 异常检测——统计方法识别异常状态码和流量尖峰
  5. 可视化输出——HTML 报告让日志分析结果一目了然