日志分析配方:从 Nginx/应用日志中提取 IP、时间、状态码

先懂格式,再写正则

写日志正则之前必须先做一件事:打开日志文件看原始行。多数人对 Nginx 日志的印象是"它有个 combined 格式",但真实服务器上的格式随时可能被运维改过。看十行原始日志,比背十条网上配方都管用。本节以最常见的 combined 格式为例:

203.0.113.7 - - [26/Oct/2024:14:32:07 +0800] "GET /api/v1/orders HTTP/1.1" 200 512

字段从左到右:客户端 IP、ident 与 user(常为 -)、[时间 时区]、"请求行"、状态码、响应字节数。每个字段一套正则片段,最后用空格拼装——这是日志解析的标准工程手法。

用命名组拆解 combined 格式

import re
LINE = re.compile(
    r'(?P<ip>\S+) (?P<ident>\S+) (?P<user>\S+) '
    r'\[(?P<time>[^\]]+)\] '
    r'"(?P<method>[A-Z]+) (?P<path>\S+) (?P<proto>[^"]+)" '
    r'(?P<status>\d{3}) (?P<bytes>\S+)'
)
m = LINE.search('203.0.113.7 - - [26/Oct/2024:14:32:07 +0800] "GET /api/v1/orders HTTP/1.1" 200 512')
print(m["ip"], m["status"], m["path"])
# 输出:203.0.113.7 200 /api/v1/orders

注意几个片段的取舍:IP 用 \S+ 而不是 \d{1,3}(?:\.\d{1,3}){3},因为日志里可能有 IPv6 或代理链;时间用 [^\]]+ 整段拿下,具体解析交给 datetime.strptime。正则负责切分,语义解析交给专用库,各干各的强项。

有了命名组,统计就是水到渠成的事:

import re
from collections import Counter
log = '''203.0.113.7 GET 200
198.51.100.3 POST 401
203.0.113.7 GET 500
203.0.113.8 GET 200'''
codes = Counter(m["status"] for m in re.finditer(r'(?P<ip>\S+) \S+ (?P<status>\d{3})', log))
print(codes.most_common())
# 输出:[('200', 2), ('401', 1), ('500', 1)]

字段片段速查表

字段 示例 片段 说明
IPv4 203.0.113.7 [0-9.]+ 或严格版限界 严格版见下文
时间戳 26/Oct/2024:14:32:07 +0800 \[[^\]]+\] 整段取出再解析
请求行 GET /path HTTP/1.1 "(?P<method>[A-Z]+) (?P<path>\S+) ([^"]+)" 拆成三分量
状态码 200 \d{3} 固定三位
字节数 512 或 - \S+ 未记录时常写作 -
Referer/UA 浏览器串 "([^"]*)" 引号内任意,双引号需转义的场景要留意

IPv4 有"宽松版"与"严格版"之争:(?:\d{1,3}\.){3}\d{1,3} 简单但放行 999.999.999.999;严格版按 0-255 分四档,准确但更长更慢:

import re
IP = r"(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}"
print(re.fullmatch(IP, "203.0.113.7") is not None)
print(re.fullmatch(IP, "999.1.1.1") is not None)
# 输出:True
# 输出:False

经验取向:做统计分析用宽松版(反正会过 strptime/int 检验),做安全风控用严格版——用途决定精度。

应用日志:level 与时间的行头解析

应用日志通常自带行头 [级别] [时间] 消息。提取 ERROR 行及其耗时数字,是排查慢请求的日常:

import re
line = "[ERROR] 2024-10-26 14:32:07 订单服务响应超时 cost=3021ms"
m = re.search(r"\[(\w+)\] (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*?cost=(\d+)ms", line)
print(m.groups())
# 输出:('ERROR', '2024-10-26 14:32:07', '3021')

.*?cost= 用懒惰量词跳到第一个耗时标记,(\d+)ms 抓数字——固定锚词(cost=)加懒惰中间量的组合,比"从行首硬数第几个字段"稳健得多。

经验之谈:服务器上的日志生态是一整套体系,从 Nginx 的 access/error 日志到宝塔这类面板聚合的站点日志,格式细节各有各的规矩。想系统性了解服务器日志都存在哪、长什么样,推荐本站《宝塔面板官方文档:文件与日志》:宝塔文件与日志。

避坑提醒:日志格式会变。框架升级、运维换格式、多实例版本不一致,都会让昨天还好好的解析脚本今天悄悄漏掉一半行。给日志解析程序加一条自检:统计"成功解析行数 / 总行数",比例突降立刻报警——没有这条监控的正则解析,是在裸奔。

从解析到报表:一个完整的迷你分析

把命名组解析与统计汇总串起来,就是最简版日志报表。任务:算出访问量最高的路径:

import re
from collections import Counter
log = """10.0.0.7 "GET /api/v1/orders" 200
10.0.0.8 "GET /api/v1/orders" 200
10.0.0.7 "GET /api/v1/users" 200
10.0.0.9 "POST /api/v1/orders" 500"""
paths = Counter(re.findall(r'"(?:GET|POST) (\S+)"', log))
print(paths.most_common(2))
# 输出:[('/api/v1/orders', 3), ('/api/v1/users', 1)]

(?:GET|POST) 用非捕获分支限定方法,(\S+) 抓路径,Counter 负责聚合——正则与宿主语言各干一半活。真实项目里把这个模式套上 open() 逐行读,就是一个能跑的访问分析脚本。"提取交给正则、聚合交给标准库、展示交给报表工具",三层各司其职,复杂度就不会堆在某一层。

解析失败也是信号:给坏行建档案

成熟的日志解析器有一条铁律:解析失败的行不能默默丢掉。格式漂移、编码异常、多行堆栈,最先暴露的都在"坏行"里:

import re
LINE = re.compile(r'(?P<ip>\S+) "(?P<method>[A-Z]+) (?P<path>\S+)" (?P<status>\d{3})')
lines = ['10.0.0.7 "GET /a" 200', '10.0.0.8 "GET /b" 200', '坏行:堆栈溢出详情……']
bad = [l for l in lines if not LINE.fullmatch(l)]
print(len(bad), bad[0][:10])
# 输出:1 坏行:堆栈溢出详

fullmatch 让"任何格式偏差"都现形,坏行单独归档、计数、告警。坏行率突增往往意味着上游变更——它比业务监控更早发现问题。解析器的健康度指标不是"解析了多少行",而是"多少行没解析出来",把这句话刻进你的日志工具里。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。