AI 技术博客
返回首页
Python 基础 24 分钟阅读

实战:nginx 访问日志解析器

Python正则表达式nginx日志分析实战

引言

第五章到此,武器已经齐了:第 1、2 篇的字符串方法与切片,第 3 篇的编码,第 4 至 6 篇的正则语法与 re 模块 API,第 7 篇的格式化深度。本篇把它们合起来打一场真仗——解析 nginx 访问日志。nginx 是一个使用极广的 Web 服务器与反向代理,默认把每一次 HTTP 请求写成一行的 combined 格式访问日志;运维同学每天要回答的问题——谁访问最多、哪些请求 404、系统有没有在报 500——答案都在这一个文件里。本篇的目标:写一个能独立运行的解析器,从一行行日志里提取 IP、时间、请求方法、路径、状态码、字节数与 User-Agent,并输出 TOP 5 IP、状态码分布、404 热点路径、PV 与独立 IP 数。所有统计输出基于本机 Python 3.12.10 对 16 行样例日志的真实运行结果,逐字可复现。

前置要求:第 4、5 篇的元字符、字符类、分组与非贪婪,第 6 篇的 re.match / groupdict,第 7 篇的对齐与制式格式化——本篇直接用,不再复述。文件读写的完整知识在第七章,这里只需会用 with open(..., encoding='utf-8') 就够了。

概念与原理

combined 日志到底长什么样

nginx 的默认日志格式叫 combined(合并格式),它对应的 log_format 声明是:

log_format combined
    '$remote_addr - $remote_user [$time_local] '
    '"$request" $status $body_bytes_sent '
    '"$http_referer" "$http_user_agent"';

八个字段依次是:客户端 IP($remote_addr)、一个占位的 -、用户名($remote_user,匿名访问时为 -)、请求时间([$time_local],形如 31/Aug/2026:14:02:11 +0800,方括号内是「日/月/年:时:分:秒 时区偏移」)、请求行($request,即 "GET /api/v1/users/42 HTTP/1.1" 这样的「方法+路径+协议版本」)、状态码($status)、响应体字节数($body_bytes_sent)、来源页($http_referer,直接访问为 -)、User-Agent($http_user_agent)。一行里引号与空格都是结构的一部分。

为什么这里必须用正则

最直白的直觉是用 split() 按空格切——第 1 篇讲过的方法。但对 combined 行,这一步立刻失败:时间字段 [31/Aug/2026:14:02:11 +0800] 内部有空格,UA 里也有空格(Mozilla/5.0 (Windows NT 10.0; ...)),按空格切出的数组长度不稳定、字段全部错位。而正则与行格式是逐字对应的:非空白序列取 IP、方括号内取时间、引号内取请求与 UA——第 4 篇的字符类 [^\]]+(排除右方括号)、[^"]*(排除双引号)在这里正好命中要害。这就是为什么前四篇花大力气讲字符类而不是只用 .*。

提取—聚合—呈现:三段式设计

解析器按数据流分成三段,各管一事:提取段用正则把每行结构化,产出字段字典(命名组 + groupdict,第 6 篇的姿势);聚合段用字典计数(字典要到第六章才系统讲,这里只当计数器用:d[key] = d.get(key, 0) + 1,第 10 篇测验会沿用它);呈现段用第 7 篇的对齐格式化输出报表。三段解耦的好处:换日志格式只改提取段,加统计指标只动聚合段,排版只动呈现段——小脚本也应该有这个工程意识。

在实际运维里,这一步做完还有下游:日志按天轮转切割(nginx 的 logrotate),多个 worker 的日志要合并排序,聚合结果要送进监控与告警系统——ELK 一类的日志平台负责存储、检索与可视化,但字段抽取的正则思路与这里完全一致。把提取段写稳,后面的路就平了。

操作与实现

第 1 步:样例、解析与统计,一条龙跑完

完整脚本把「写样例日志 → 正则解析 → 字典聚合 → 报表输出」四件事一次做完。这样每个代码块都可以独立运行,不需要任何前置文件;脚本会把 16 行样例写入当前目录的 access.log。样例里混入了桌面 Chrome、Firefox、iPhone Safari、curl 探测与 Python 脚本五种客户端,状态码覆盖 200/304/404/500,404 集中在扫描路径上,行格式与真实 nginx combined 输出一致(内容为教学构造):

import re
log_lines = [
    '203.0.113.5 - - [31/Aug/2026:14:02:11 +0800] "GET /api/v1/users/42 HTTP/1.1" 200 213 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"',
    '203.0.113.5 - - [31/Aug/2026:14:02:18 +0800] "GET /static/css/app.css HTTP/1.1" 304 0 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"',
    '198.51.100.23 - - [31/Aug/2026:14:05:42 +0800] "GET /api/v1/search?q=python HTTP/1.1" 200 1842 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"',
    '198.51.100.23 - - [31/Aug/2026:14:06:03 +0800] "POST /api/v1/login HTTP/1.1" 200 96 "-" "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"',
    '192.0.2.77 - - [31/Aug/2026:14:10:55 +0800] "GET /static/img/logo.svg HTTP/1.1" 304 0 "http://example.com/" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"',
    '192.0.2.77 - - [31/Aug/2026:14:11:20 +0800] "GET /api/v1/timeline HTTP/1.1" 200 4521 "http://example.com/home" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"',
    '203.0.113.5 - - [31/Aug/2026:14:12:47 +0800] "GET /api/v1/users/42/posts HTTP/1.1" 200 3380 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"',
    '45.33.10.9 - - [31/Aug/2026:14:15:01 +0800] "GET /robots.txt HTTP/1.1" 404 153 "-" "curl/8.5.0"',
    '45.33.10.9 - - [31/Aug/2026:14:15:02 +0800] "GET /wp-admin/login.php HTTP/1.1" 404 153 "-" "curl/8.5.0"',
    '45.33.10.9 - - [31/Aug/2026:14:15:03 +0800] "GET /favicon.ico HTTP/1.1" 404 153 "-" "curl/8.5.0"',
    '203.0.113.5 - - [31/Aug/2026:14:22:36 +0800] "GET /api/v1/users/42 HTTP/1.1" 200 213 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"',
    '203.0.113.9 - - [31/Aug/2026:14:23:14 +0800] "GET /api/v1/search?q=regex HTTP/1.1" 200 2310 "-" "python-requests/2.32.3"',
    '203.0.113.9 - - [31/Aug/2026:14:23:15 +0800] "GET /api/v1/stats HTTP/1.1" 500 78 "-" "python-requests/2.32.3"',
    '192.0.2.77 - - [31/Aug/2026:14:30:52 +0800] "GET /static/js/main.js HTTP/1.1" 304 0 "http://example.com/home" "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"',
    '203.0.113.5 - - [31/Aug/2026:14:33:08 +0800] "GET /api/v1/users/42 HTTP/1.1" 200 213 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"',
    '45.33.10.9 - - [31/Aug/2026:14:35:22 +0800] "GET /wp-admin/login.php HTTP/1.1" 404 153 "-" "curl/8.5.0"',
]

with open('access.log', 'w', encoding='utf-8') as f:
    f.write('\n'.join(log_lines) + '\n')
PAT = re.compile(
    r'^(?P<ip>\S+) - (?P<user>\S+) \[(?P<time>[^\]]+)\] '
    r'"(?P<method>[A-Z]+) (?P<path>\S+)(?: HTTP/\S+)?" '
    r'(?P<status>\d{3}) (?P<bytes>\d+|-) "(?P<referer>[^"]*)" "(?P<ua>[^"]*)"$'
)

records = []
with open('access.log', encoding='utf-8') as f:
    for line in f:
        m = PAT.match(line)
        if m is not None:
            records.append(m.groupdict())

print('解析成功行数:', len(records))
print('字段演示(第一条):')
for key in ('ip', 'time', 'method', 'path', 'status', 'bytes', 'ua'):
    print(f'{key:<7}: {records[0][key]}')
ip_counts, status_counts, not_found = {}, {}, {}
for rec in records:
    ip_counts[rec['ip']] = ip_counts.get(rec['ip'], 0) + 1
    status_counts[rec['status']] = status_counts.get(rec['status'], 0) + 1
    if rec['status'] == '404':
        not_found[rec['path']] = not_found.get(rec['path'], 0) + 1

print('\n=== TOP 5 IP ===')
for ip, cnt in sorted(ip_counts.items(), key=lambda kv: kv[1], reverse=True)[:5]:
    print(f'{ip:<16}{cnt:>4}')
print('=== 状态码分布 ===')
for code, cnt in sorted(status_counts.items()):
    print(f'{code}: {cnt}')
print('=== 404 最多的路径 TOP 3 ===')
for path, cnt in sorted(not_found.items(), key=lambda kv: kv[1], reverse=True)[:3]:
    print(f'{path:<24}{cnt:>4}')
print('=== 概览 ===')
print(f'PV: {len(records)},独立 IP: {len(ip_counts)}')
解析成功行数: 16
字段演示(第一条):
ip     : 203.0.113.5
time   : 31/Aug/2026:14:02:11 +0800
method : GET
path   : /api/v1/users/42
status : 200
bytes  : 213
ua     : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36

=== TOP 5 IP ===
203.0.113.5        5
45.33.10.9         4
192.0.2.77         3
198.51.100.23      2
203.0.113.9        2
=== 状态码分布 ===
200: 8
304: 3
404: 4
500: 1
=== 404 最多的路径 TOP 3 ===
/wp-admin/login.php        2
/robots.txt                1
/favicon.ico               1
=== 概览 ===
PV: 16,独立 IP: 5

对照输出解读这段脚本。提取段:\S+ 取非空白字段(IP 与用户名),[^\]]+ 取方括号内的整个时间串,[A-Z]+ 是方法、\S+ 是路径(query 串跟着路径走,/api/v1/search?q=python 是一个整体)、(?: HTTP/\S+)? 是可选协议版本——为什么可选?因为真实环境还有 HTTP/2.0,写死 HTTP/1.1 会把 2.0 的行拒之门外;\d{3} 是状态码,(?:\d+|-) 是字节数(真实日志偶尔记 -),[^"]* 是引号内字段(允许为空)。用 PAT.match 而非 PAT.search,是因为 match 锚定行首(第 6 篇),配合 ^ 与 $ 锚定整行——一条日志必须完整符合结构才算合法,多一个字段少一个空格都算坏行。聚合段:计数统一用 d[key] = d.get(key, 0) + 1,排序用内置 sorted 按值降序(key=lambda kv: kv[1]),[:5]、[:3] 截取前几名,同名次按日志中首次出现顺序排列(sorted 是稳定排序)。呈现段:IP 左对齐 16 宽、计数右对齐 4 宽,路径 24 宽左对齐——全部是第 7 篇的语法。

读这份报表,结论一目了然:45.33.10.9 是一个 curl 扫描器(连打 robots.txt、wp-admin、favicon 全是 404),/wp-admin/login.php 是被试探最多的路径,唯一的 500 来自 203.0.113.9 的 /api/v1/stats——攻击面与故障点都定位到了。真实场景里,这些数字会成为自动化告警的输入;IP 归属地需要 GeoIP 类数据库才能判断,日志本身看不出来,本篇不越界。

这些数字本身也可以直接喂给自动化:连续 N 个 5xx 就触发告警、单个 IP 的 404 占比超过阈值就进入黑名单候选、下载流量排行决定 CDN 预热清单——判断与动作属于业务逻辑,本篇只保证数据准确、可复现地到达这一步。报表排版你可以按需换成 CSV 或者 JSON,聚合逻辑一行都不用动。

第 2 步:坏行防御

真实日志不可能每一行都干净:防火墙会插入伪造行、文件被截断、进程崩溃留下半行。解析器的第一原则是坏行不能拖垮全量统计。实测一个坏行 + 一个字节数为 - 的行:

import re

PAT = re.compile(
    r'^(?P<ip>\S+) - (?P<user>\S+) \[(?P<time>[^\]]+)\] '
    r'"(?P<method>[A-Z]+) (?P<path>\S+)(?: HTTP/\S+)?" '
    r'(?P<status>\d{3}) (?P<bytes>\d+|-) '
    r'"(?P<referer>[^"]*)" "(?P<ua>[^"]*)"$'
)
sample = [
    '10.0.0.1 - - [31/Aug/2026:15:00:00 +0800] "GET /a HTTP/1.1" 200 512 "-" "ok"',
    '这行不是日志,会被跳过',
    '10.0.0.2 - - [31/Aug/2026:15:00:05 +0800] "GET /b HTTP/1.1" 200 - "-" ""',
]
for line in sample:
    m = PAT.match(line)
    if m is None:
        print('跳过坏行:', line[:22])
        continue
    d = m.groupdict()
    size = 0 if d['bytes'] == '-' else int(d['bytes'])
    print(d['ip'], d['path'], d['status'], 'bytes=', size)
10.0.0.1 /a 200 bytes= 512
跳过坏行: 这行不是日志,会被跳过
10.0.0.2 /b 200 bytes= 0

两件事值得注意:m is None 的行被跳过并计数(工程上应把跳过数统计出来写进报表,而不是悄悄吞掉);字节数为 - 的行先判 == '-' 再转 int,否则 int('-') 直接抛 ValueError。第三行还演示了空 UA(""):[^"]* 允许匹配空串,字段不缺失——这正是第 5 篇「引号内字段用排除字符类而非 .*?」的理由:.*? 的非贪婪虽也能工作,但 [^"]* 更直白、更快、且不可能跨引号。

第 3 步:不同日志格式 → 正则版本差异

nginx 的日志格式不是写死的:运维可以自定 log_format 增删字段。格式每变一次,正则就要换一个版本,这是日志解析的日常。实测三种常见变体——标准 combined、去掉 referer/UA 的简化版、行尾追加 $request_time(响应耗时秒数)的扩展版——同一组行,只有正确的版本能匹配:

import re

mid = r'^(\S+) - \S+ \[[^\]]+\] "([A-Z]+) (\S+)(?: HTTP/\S+)?" (\d{3}) \d+'
combined = re.compile(mid + r' "[^"]*" "[^"]*"$')
common = re.compile(mid + r'$')
extended = re.compile(mid + r' "[^"]*" "[^"]*" (\d+\.\d+)$')

lines = [
    '66.249.66.1 - - [31/Aug/2026:14:00:01 +0800] "GET / HTTP/1.1" 200 1034 "-" "Googlebot/2.1 (+http://www.google.com/bot.html)"',
    '203.0.113.5 - - [31/Aug/2026:14:00:02 +0800] "GET /index.html HTTP/1.1" 200 6213 "-" "-" 0.041',
]
for name, pat in [('combined 版', combined), ('common 版', common), ('extended 版', extended)]:
    m1 = pat.match(lines[0]) is not None
    m2 = pat.match(lines[1]) is not None
    print(f'{name}: 标准 combined 行 -> {m1},尾部多 request_time 的行 -> {m2}')
combined 版: 标准 combined 行 -> True,尾部多 request_time 的行 -> False
common 版: 标准 combined 行 -> False,尾部多 request_time 的行 -> False
extended 版: 标准 combined 行 -> False,尾部多 request_time 的行 -> True

换成别的格式时,思路是机械的:数一数格式里有几个字段、字段间用什么分隔,把正则尾段改到与格式逐字对应,并用 $ 锚死结尾(否则扩展版那行会被前一个版本「前缀匹配」吞掉——锚定整行是第 6 篇 fullmatch 精神的延续)。多格式并存的环境,给每份日志按其格式选版本,把模式集中管理,格式升级只动一处。

易错点与陷阱

陷阱一:想用 split 按空格切行

路径、query、UA、时间字段内部都可能有空格,line.split() 出来的数组字段全错位。判断「能不能用 split」的标准:分隔符是否可能在字段内部出现。combined 行的空格在 UA 里必然出现,所以必须正则;而字段间用 | 分隔的自定义 CSV 就完全可以用 rsplit 按 | 切。先问格式,再选工具。

陷阱二:坏行直接崩溃整场统计

不加 m is None 防御、或直接写 records.append(PAT.match(line).groupdict()),一行格式异常就让整个解析器抛 AttributeError(None 没有 groupdict)——16 行日志丢 1 行没问题,100 万行日志丢 1 行就是重新跑一遍。统一姿势:匹配失败计跳过数、继续循环,全部跑完再汇报「解析成功 N 行、跳过 M 行」。生产环境还常配合「坏行落盘」:摸不清坏行规律时,先把跳过的行原样写进 bad.log,人工抽查后再决定是修正则还是修数据源——先保数据,再谈质量。

陷阱三:int(’-’) 与看似数字的字段

字节数字段在真实日志里可能是 -(连接中断等场景 nginx 记 -),直接 int(rec['bytes']) 抛 ValueError。同理,状态码虽然由 \d{3} 匹配,解析后仍是字符串,做数值比较(如 int(status) >= 500)前要显式转换。字符串与数字的分界,是解析类代码最常见的类型暗坑。

陷阱四:一个正则套所有格式

把 combined 的正则拿去解析扩展版日志,扩展版全部匹配失败($ 之前多了一截 0.041);拿扩展版解析 combined,又全部失败。正则版本必须与日志格式一一对应,且要用 ^/$ 锚定整行,避免「前缀匹配成功」的假阳性。上线前用一小段样本日志给每个版本做冒烟测试,比出问题后再对比格式省事得多。

小结

本篇用 combined 格式的 16 行样例日志走完了一条完整的数据链路:log_format 字段结构 → 对应正则(命名组 + groupdict 提取七个字段)→ 字典聚合(TOP 5 IP、状态码分布、404 热点 TOP 3、PV 与独立 IP)→ 格式化报表(第 7 篇语法)。核心结论有三:combined 行的结构决定了必须用正则而不是 split;解析器必须对坏行与 - 字节数做防御;日志格式一变,正则版本要跟着变,且锚定整行。至此第五章的字符串与正则技能全部落地——下一篇《文本清洗工具链》会把同类手法用在更「脏」的非结构化文本上,综合测验则在两篇实战之后等你。

练习与思考题

  1. 在正文模式的基础上,把请求字段变成两个命名组,单独提取协议版本(如 HTTP/1.1)并统计各版本占比(提示:(?: HTTP/(?P<version>\S+))?,注意可选组未命中时 groupdict 值为 None)。
  2. 给解析器增加两个指标:POST 请求占总请求的百分比(输出形如 POST 占比: 12.5%,用第 7 篇的 % 类型)与平均每次请求的响应字节数(bytes 求和除以行数)。
  3. 用 ua 字段按关键字把客户端归组(如 'curl' 在 ua 中 → 爬探、'iPhone' → 移动端、'Firefox' → Firefox、其余 → 其他)并统计各组数量(提示:key in ua 判断 + 字典计数),比对正文报表里的 IP 结论是否互相印证。
  4. 思考题:第 5 篇讲非贪婪量词时说过 .*? 也能匹配引号内内容,为什么本篇坚持用 [^"]*?在「UA 字段末尾多一个字符」与「请求字段被截断(引号缺失)」两种情形下,分别推演两种写法的结果差异。