分析 Nginx 访问日志,在里面挖点宝
前段时间的网站缓存那篇文章,里面就提到了一个很重要的问题:缓存不是一件放之四海而皆准的事,而是需要根据站点情况来打磨,才能达到好效果。
打磨,那当然是要有理有据的了,比如哪个路径访问频次高,哪个页面用到了POST。与其费劲寻找第三方统计工具,不如先在Nginx的访问日志里面分析一下,找一找那些平日里并不容易被发现的小线索,甚至,可能还有惊喜。
像不像要分析的日志?快来试试吧!1. 前言
自从建站开始,就没怎么关注过Nginx日志,而Linux也会用logrotate自动做日志轮转,所以放着不管也不会出问题。不过,毕竟这些日志可以看做是一种统计信息,所以一直想要找个时间找个办法去整理一下。但可惜,每次都拖延症发作,于是就一直拖,一直拖,拖到现在,才决定折腾一下。
本篇不确定算不算水文,但目前确实在关注这一块。虽然我敢打包票,这个世界上肯定有成品化的日志分析器,但最后还是决定自己动手写;另外,因为没有付费买AI订阅的原因,大部分代码依然在使用『古法编程』而非Vibe Coding,所以整个折腾过程就当作是学习了。
2. 日志解包整理
2.1 保存格式
一般情况下,大部分发行版的Nginx带的默认配置,都会开启记录访问日志的功能,比如笔者目前正在用的配置文件,是在默认配置上修改的,大概就如下所示:
http {
# 其他内容省略
log_format main '$remote_addr - - [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_cf_connecting_ip"'
'"$http_cf_ray" "$http_host"';
access_log /var/log/nginx/access.log main;
}Nginx在运行的时候,就按照这里设置的模板生成日志字符串,一行一条,追加写入。需要注意的是,在http块里面配置的access_log,其有效范围是所有站点。如果想要按站点分析,要么是把这条命令移动到对应的server块之下,要么是在后续的程序中按照host字段来筛选。
此外,如果是通过包管理器安装的Nginx,在安装的时候,管理器会自动帮你设置日志轮转,其配置文件放在/etc/logrotate.d/nginx中,用编辑器打开,可以看到其默认配置:
/var/log/nginx/*.log {
daily
missingok
rotate 52
compress
delaycompress
notifempty
create 640 nginx adm
sharedscripts
postrotate
if [ -f /run/nginx.pid ]; then
kill -USR1 `cat /run/nginx.pid`
fi
endscript
}在这个情况下,当天的日志存储在access.log中,昨天的日志存在access.log.1中,再往前52天的日志,则自动使用gz压缩,命名为access.log.X.gz(X为日志序号)
因此我们碰到了两种保存格式:纯文本,以及gz压缩包。
2.2 开门,放蟒蛇
面对用各种方法下载下来的,成堆的日志,想要转换为有一定格式标准的数据,最趁手的东西,当然是Python了。部分计算机高手可能会使用Bash脚本(调用各种程序)来处理日志,应该说,我很佩服他们的勇气和高超的技艺,但如果让我来写,算了吧,我还是宁可继续写那本宁雨x杏泉的小说,虽然目前依然没写完,好歹三年时间能写28章出来,比写Bash脚本快多了。
话题回来。流式格式有很多,比如说CSV。但这玩意结构较为松散,不太适合机读(写schema有些麻烦),所以不用它。这里选择使用JSONL,也就是一行一条合法的JSON数据。好处同样是不必一次全部读入内存,也不必在文件里面乱跳,直接按行解析即可。而且他的解析器支持很广泛,假如以后想换个软件来处理,迁移也方便。
日志分析从读文件开始,先建一个read_file.py:
import gzip
import os
# 传入给定目录,自动遍历里面的文件
# 这里写成生成器的形式
def traverse_dir(log_dir):
for fname in os.listdir(log_dir):
fpath = os.path.join(log_dir, fname)
if not os.path.isfile(fpath):
continue
yield fpath, fname
# 传入具体路径,根据文件后缀来确定读取方式
# 同样是写成生成器,最后逐行读取
def read_log_file(path):
if path.endswith(".gz"):
with gzip.open(path, "rt", encoding="utf-8", errors="ignore") as f:
for line in f:
yield line.strip()
else:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
for line in f:
yield line.strip()读入文件之后就需要解析,用正则就好。新建regex.py:
import re
# 预编译正则表达式
# 这里使用命名捕获组,后面可以直接作为字典键
# 最后两个字段为可选,是因为部分日志是在调整为配置文件里的
# 日志格式之前生成的,不包含新字段
# 因此在匹配结果中为None
# 需要注意的是,这里需要用到非贪婪匹配(最短匹配)模式
# 也就是在量词后加上问号(例如 *? )
LOG_PATTERN = re.compile(
r'(?P<connIP>.*?)\s-\s-\s'
r'\[(?P<time>.*?)\]\s'
r'"(?P<reqMethod>.*?)\s'
r'(?P<path>.*?)\s'
r'(?P<httpVersion>.*?)"\s'
r'(?P<statusCode>\d{3})\s'
r'(?P<bytesSendedCount>\d+)\s'
r'"(?P<referer>.*?)"\s'
r'"(?P<userAgent>.*?)"\s'
r'"(?P<userIP>.*?)"\s?'
r'("(?P<cfRay>.*?)"\s?)?'
r'("(?P<host>.*?)"\s?)?'
)
def parse_line(line):
m = LOG_PATTERN.match(line)
if m:
return m.groupdict()
return {}最后新建main.py,拼合以上逻辑:
from datetime import datetime
import json
import regex
import read_file
# 日志源文件目录
log_dir = 'logs/'
# JSONL输出目录
out_dir = 'out-jsonl'
for fpath, fname in read_file.traverse_dir(log_dir):
if not fname.startswith('access'):
# 不是访问日志,就跳过
continue
print(f'-------- 正在处理:{fname} ----------')
with open(f'{out_dir}/{fname}.jsonl', 'w', encoding='utf-8') as f:
for line in read_file.read_log_file(fpath):
# 调用正则函数解析日志行
pLine = regex.parse_line(line)
# 对数据进行简单清洗
# 这里主要是调一下时间格式
pTime = datetime.strptime(
pLine['time'], '%d/%b/%Y:%H:%M:%S %z'
)
pLine['time'] = ' '.join([
str(pTime.date()), str(pTime.time())
])
# 写文件,保存为JSONL
# 也就是一行一条合法JSON
f.write(json.dumps(pLine))
f.write("\n")最后运行main.py,就能拿到一个个整理好的,有结构的JSONL文件了。
部分日志截图,connIP为CF回源IP3. 日志分析
拿到有结构的日志之后,下一步就是进行分析处理。这方面的工具就很多了,比如用Pandas或者DuckDB来获取数据,Matplotlib与Seaborn来做统计图,Plotly 做交互式图表,按需选择即可。
笔者并没有用这些比较复杂的工具,一来目前没有太大的自动化需求,二来就是有些懒得学,三么,上面也说了,暂时没有给AI充钱,所以也不能让他来代劳。不过,如果像笔者这样,闲着没事干临时看看的,其实有一个很简单粗暴的分析方法:用Python直接读数据文件和做分析逻辑,写CSV,最后用Excel打开,手动做可视化。
遍历文件夹,读取JSONL文件,逐行解析,这部分的逻辑很简单,和上面差不太多,甚至可以直接复用那个read_file.py,最后解析出来的应该是一个字典,内含每行JSON日志所包含的信息。有了这个,就可以编写相应的Python代码,进行分析了。
除了引言中提到的,优化缓存时可以用来参考之外,下面再举两个例子。
3.1 都在用什么RSS阅读器?
一个很简单的思路是,获取访问RSS路径的软件的UA信息,然后插入到列表中。接着,对这个列表进行去重处理,并对重复项进行计数,就能知道有多少种RSS阅读器,每种有多少。虽然很显然,此时『小众但高频请求的阅读器』出现次数会比『泛用但低频请求的阅读器』要多,但我们只是定性分析而已,不需要那么精密。
转换成核心逻辑如下:
import json
import csv
import read_file
from collections import Counter
# 遍历jsonl并逐行解析
log_dir = 'out-jsonl'
ua = []
for fpath, fname in read_file.traverse_dir(log_dir):
if not fname.endswith('.jsonl'):
continue
print(f'正在读取:{fname}')
with open(fpath, 'r', encoding='utf-8') as f:
for l in f:
line = l.strip()
# 解析 jsonL
try:
j = json.loads(line)
except:
continue
# 数据选择逻辑
if (
(j['reqMethod'] == 'GET')
and (j['path'].startwith('/index.php/feed/'))
):
ua.append(j['userAgent'])
# 统计次数
# 这里用的是 collections.Counter
data = [['User-Agent', '请求次数']]
c = dict(Counter(ua))
for k, v in c.items():
data.append([str(k), str(v)])
# 将统计结果写入文件
out_dir = 'out'
outFile = f'{out_dir}/rss.csv'
print(f'-- 正在写出:{outFile}')
with open(outFile, 'w', encoding='utf-8', newline='') as f:
w = csv.writer(f)
w.writerows(data)最后得到的就是干净的CSV文件,用你喜欢的表格软件打开即可。如果不想切换软件,VSCode里也有个插件叫Spreadsheet Viewer的,可以用来预览CSV:
用插件检查检查UA的结果倒是有些大开眼界,目前来看,访问用户有如下来源:
- 普通RSS阅读器,如FreshRSS
- 博客聚合站,如博友圈
- Telegram 机器人,如rssbot,订阅后推送到tg
- 在线(SaaS)式阅读器,如inoReader
- 浏览器直接访问
最后三种比较有趣。tg机器人是请求频率最高的,从后台观察来看,每个实例每五分钟就会拉一次RSS,可能是为了尽快让新文章推送到订阅者通知里(毕竟,即时通讯软件嘛);在线式阅读器比较文明,拉取一次,然后在平台内部分发给多个订阅了此站点的读者,不必重复请求;浏览器直接访问,大概率是发现RSS图标后直接点击,浏览器自动请求了一次而已,很正常。
3.2 怎么过来的?
HTTP中有一个Referer头,用来表示『我从何处链接过来的』,因此分析这个标头,就可以大致知道自己的站点/文章被分享到了何处。
# 这里只需要修改几个地方,如取数据逻辑,以及CSV标头
# 为简便起见,只给出关键部分
referer = []
if (
(j['reqMethod'] == 'GET')
and (j['host'] == 'blog.mfwt.top')
and (j['path'] == '/' or j['path'].startswith('/index.php/'))
and ('mfwt.top/' not in j['referer'])
and ('mfwt.top:' not in j['referer'])
# ....以及其他排除站内访问的逻辑
):
referer.append(j['referer'])同样地,我们可以得到各个Referer来源的数量统计:
这里的截图没有去除站内访问显然,我们依然能从中获得一些有用的信息:
- 有很多请求是直接访问的,这部分应该包括爬虫,复制链接打开,以及其他不记录Referer的来源(如设置了
rel="noreferrer"的跳转页),等等 - 搜索引擎依然是流量大头
- 博客聚合站也占比不小
- GitHub上的一些博客聚合项目,本站有幸被部分项目主动收录。有点惋惜的是,点进去这些项目里,随便挑了几个博客看,发现已经有一部分无法打开,或者最后一次更新已经是七八年前的事情了,令人唏嘘
- 也有一些是被人主动引用,例如里面出现了来自
linux.do的访问,而笔者没有那个站点的账号,显然不可能自己发帖 xxxxx.isolation.zscaler.com,查了一下,这玩意应该是Zscaler的『零信任浏览器』生成的隔离域名。这玩意相当于就是远端跑一个浏览器,渲染画面,然后传输视频流到终端,从而杜绝恶意脚本对终端(及终端所在网络)的损害,挺有趣的。- 还有部分是莫名其妙的恶意请求,例如,出现了内容农场链接到本站的情况,大概率只是『页面一打开,有成千上万个链接』的那种罢了,没有什么影响
4. 写在最后
简单水文一篇,不过确实学到了东西。由于日志较多,还有待慢慢发掘,也许以后还能找到什么新东西也说不定。
至于缓存优化的事情,来日方长,来日方长啊....
(完)