Материал представляет собой мысли о том как можно подойти к анализу логов Nginx при помощи библиотеки
pandas
.
Первое, что нужно сделать - это настроить разделители полей создаваемого сервером Nginx log-файла. Настраивается он в файле nginx.conf
строкой log_format main ...
в секции http
. По умолчанию, формат строки логов имеет вид:
http { ... # формат логирования в Nginx по умолчанию # log_format combined '$remote_addr - $remote_user [$time_local] ' # '"$request" $status $body_bytes_sent ' # '"$http_referer" "$http_user_agent"'; # формат необходимо привести к следующему виду log_format main '$time_iso8601|$remote_addr|$status|$request_method' '|$request_uri|$http_referer|$http_user_agent'; ... }
Обратите внимание на разделители
|
столбцов. В предложенный формат логирования можно добавить нужные параметры, в этом случае, добавленным столбцам вDataFrame
необходимо будет дать соответствующие имена.
Список переменных Nginx, которые можно использовать при настройке формата лога:
$args
- аргументы в строке запроса$binary_remote_addr
- адрес клиента в бинарном виде, длина значения всегда 4 байта для IPv4-адресов или 16 байт для IPv6-адресов$body_bytes_sent
- число байт, переданное клиенту, без учёта заголовка ответа; переменная совместима с параметром “%B” модуля Apache $bytes_sent
- число байт, переданных клиенту (1.3.8, 1.2.5)$connection
- порядковый номер соединения (1.3.8, 1.2.5)$connection_requests
- текущее число запросов в соединении (1.3.8, 1.2.5)$connection_time
- время соединения в секундах с точностью до миллисекунд (1.19.10)$content_length
- поле “Content-Length” заголовка запроса$content_type
- поле “Content-Type” заголовка запроса$document_uri
- то же, что и $uri$host
- в порядке приоритета: имя хоста из строки запроса, или имя хоста из поля “Host” заголовка запроса, или имя сервера, соответствующего запросу$hostname
- имя хоста$query_string
- то же, что и $args$realpath_root
- абсолютный путь, соответствующий значению директивы root или alias для текущего запроса, в котором все символические ссылки преобразованы в реальные пути$remote_addr
- адрес клиента$remote_port
- порт клиента$remote_user
- имя пользователя, использованное в Basic аутентификации$request
- первоначальная строка запроса целиком$request_completion
- “OK” если запрос завершился, либо пустая строка$request_filename
- путь к файлу для текущего запроса, формируемый из директив root или alias и URI запроса$request_id
- уникальный идентификатор запроса, сформированный из 16 случайных байт, в шестнадцатеричном виде (1.11.0)$request_length
- длина запроса (включая строку запроса, заголовок и тело запроса) (1.3.12, 1.2.7)$request_method
- метод запроса, обычно “GET” или “POST”$request_time
- время обработки запроса в секундах с точностью до миллисекунд; время, прошедшее с момента чтения первых байт от клиента до момента записи в лог после отправки последних байт клиенту$request_uri
- первоначальный URI запроса целиком (с аргументами)$scheme
- схема запроса, “http” или “https”$server_addr
- адрес сервера, принявшего запрос. Получение значения этой переменной обычно требует одного системного вызова. Чтобы избежать системного вызова, в директивах listen следует указывать адреса и использовать параметр bind.$server_name
- имя сервера, принявшего запрос$server_port
- порт сервера, принявшего запрос$server_protocol
- протокол запроса, обычно “HTTP/1.0”, “HTTP/1.1”, “HTTP/2.0” или “HTTP/3.0”$status
- статус ответа (1.3.2, 1.2.2)$time_iso8601
- локальное время в формате по стандарту ISO 8601 (1.3.12, 1.2.7)$time_local
- локальное время в Common Log Format (1.3.12, 1.2.7)$tcpinfo_rtt
, $tcpinfo_rttvar
, $tcpinfo_snd_cwnd
, $tcpinfo_rcv_space
- информация о клиентском TCP-соединении; доступна на системах, поддерживающих параметр сокета TCP_INFO$uri
- текущий URI запроса в нормализованном виде$msec
- время в секундах с точностью до миллисекунд на момент записи в лог$pipe
- “p” если запрос был pipelined, иначе “.”DataFrame
.Согласно НОВОМУ формату журнала Nginx - первый столбец представляет собой дату и время запроса, следовательно делаем из нее индексные метки строк.
import pandas as pd # в текстовом формате log = pd.read_csv('access.log', sep="|", index_col=0) # в формате `gzip` log = pd.read_csv('access.log.2.gz', sep="|", compression='gzip', index_col=0)
Затем необходимо преобразовать полученные индексные метки строк в метки времени, т.е. в дату и время, что бы можно было отбирать нужный срез времени.
# преобразуем текстовые метки строк в метки даты и времени log.index = pd.to_datetime(log.index) log.index.name = 'time' # теперь можно отбирать строки по временному интервалу log.loc[(log.index > '2023-12-17 18:00') & (log.index < '2023-12-17 18:10')]
Следующий шаг, необходимый для удобства работы с данными лог-файла, это дать имена столбцам в DataFrame
.
# пересохраняем `DataFrame` log = log.set_axis(['ip', 'code', 'resp', 'url', 'ref', 'agent'], axis='columns')
Смотрим и считаем коды ответа Nginx сервера:
>>> log['code'].value_counts() # code # 200 441821 # 403 865 # 425 629 # 404 591 # 308 391 # 429 15 # 499 6 # 304 2 # 400 1 # Name: count, dtype: int64
Подробнее о методе
.value_counts()
объектовDataFrame
/Series
.
Смотрим на каких URL спотыкался индексирующий робот Yandex:
>>> log[log['agent'].str.contains(r'yandex.+?bots', case=False, regex=True) & ... (log['code'] == 404)][['code', 'url', 'agent']]
Можно отбирать/фильтровать поля при помощи регулярных выражений. Обратите внимание на тильду ~
- это отрицание, т.е. отбираем все кроме строк с regex r'bots?'
(простенькое, для примера) и делаем поиск регистронезависимый case=False
. Больше о фильтрации данных смотрите в материале "Отбор и фильтрация данных в Pandas".
# сохраняемся в новый `DataFrame` >>> regexp_str = r'bots?|\-' >>> log_user = log[~log['agent'].str.contains(regexp_str, case=False, regex=True)] # смотрим ответы сервера без ботов >>> log_user['code'].value_counts() # code # 200 14252 # 403 853 # 404 263 # 425 205 # 308 28 # 499 5 # 304 2 # 400 1 # Name: count, dtype: int64 # для нескольких условий, например, все НЕ роботы с ошибкой 404 >>> log[~log['agent'].str.contains(r'bots?|\-', case=False, regex=True) & (log['code'] == 404)]
Для больших регулярок, наверное лучше компилировать шаблон регулярного выражения и использовать встроенный модуль re
совместно с методом Series.apply()
, который принимает в качестве аргумента пользовательскую функцию:
>>> import re # компилируем регулярку >>> pt = re.compile(r'bots?|\-', flags=re.I) # пользовательская функция - фильтр должна возвращать значение типа `bool` # т.е. `True` для значений которые отбираем и `False` - для пропуска >>> def regexp(item): ... return True if re.findall(pt, item) else False ... >>> tmp = log[~log['agent'].apply(regexp)] >>> tmp['code'].value_counts() # code # 200 14252 # 403 853 # 404 263 # 425 205 # 308 28 # 499 5 # 304 2 # 400 1 # Name: count, dtype: int64
На каждую ошибку можно посмотреть следующим образом с показом только интересующих столбцов:
# ошибка 404 >>> log_user[log_user['code'] == 404][['ip','url', 'resp']] # или 429 >>> log_user[log_user['code'] == 425][['ip','url', 'resp']]
# сохраним полученный `Series` в переменную `ip` >>> ip = log_user['ip'].value_counts() >>> ip.head() # ip # 5.181.60.48 1100 # 5.181.60.16 1094 # 5.181.60.137 667 # 34.243.126.62 381 # 37.230.131.94 361 # Name: count, dtype: int64
# Ошибка Nginx 425 (смотрим, кто пытается положить сайт) >>> (log_user[log_user['code'] == 425][['ip']].value_counts()).head() # ip # 77.73.129.20 117 # 195.96.137.6 45 # 97.107.133.248 13 # 82.145.215.206 12 # 195.211.23.206 6 # Name: count, dtype: int64 # Ошибка Nginx 403 (смотрим, кому больше всех надо) >>> (log_user[log_user['code'] == 403][['ip']].value_counts()).head() # ip # 37.230.131.94 361 # 37.230.131.97 335 # 45.86.1.238 3 # 109.110.66.89 2 # 185.244.20.203 2 # Name: count, dtype: int64
>>> ip[ip > 10] # ip # 5.181.60.48 1100 # 5.181.60.16 1094 # 5.181.60.137 667 # 34.243.126.62 381 # 37.230.131.94 361 # ... # 178.34.158.114 11 # 31.181.43.71 11 # 95.24.212.123 11 # 109.191.243.99 11 # 95.158.218.156 11 # Name: count, Length: 100, dtype: int64 # для детального анализа можно # все это записать в `csv` файл >>> ip[ip > 10].to_csv('ip.csv') # или `excel` файл >>> ip[ip > 10].to_excel("ip.xlsx", sheet_name="Лист1")
user-agent
.>>> log_user[log_user['ip'] == '5.181.60.48'][['url', 'agent']] # можно посмотреть менялся ли у этого IP `user-agent` # то есть пытались запутать следы или как? >>> log_user[log_user['ip'] == '5.181.60.48'][['agent']].value_counts() # agent # Relap fetcher 1100 # Name: count, dtype: int64
User-agent
IP-адреса 5.181.60.48 не менялся и называется Relap fetcher
- это бот VK. Если бы user-agent
менялся, то было бы несколько записей в столбце agent
>>> log_user['url'].value_counts()[:50]
А еще можно рисовать графики. Для этого ставим модуль matplotlib
в виртуальное окружение где стоит pandas
# активируем виртуальное окружение $ source .venv/bin/activate # ставим модуль `matplotlib` (VirtualEnv) Idea@Centre:~$ python3 -m pip install -U matplotlib
Рисуем график посещаемости. Аргумент rule
метода .resample()
означает смещение/период за которые подсчитываются URL, чтобы поставить точку на графике. В данном случае rule='H'
означает 1 час.
>>> from matplotlib import pyplot as plt >>> visits = log_user['url'] >>> visits = visits.resample(rule='H', kind='period').count() >>> visits.plot() >>> plt.show()
Или как часто получали ошибку 403 (разбиваем на периоды по 15 минут):
>>> err403 = log_user[log_user['code'] == 403][['ip']] >>> err403 = err403.resample(rule='15min', kind='period').count() >>> err403.plot() # <Axes: xlabel='2023-12-17T00:00:19+03:00'> >>> plt.show()
Например, нарисуем график распределения HTTP-кодов ответа сервера по времени:
# список HTTP-кодов ответа можно получить # из серии `log_user['code']` >>> status = log_user['code'].value_counts().index >>> status # Index([200, 403, 404, 425, 308, 499, 304, 400], dtype='int64', name='code') # создадим пустой `DataFrame` >>> df = pd.DataFrame() # пройдемся по списку `status` и добавим столбцы # с именем `df[code]` в созданный пустой `DataFrame` >>> for code in status: ... tmp = log_user[log_user['code'] == code][['ip']] ... df[code] = tmp.resample(rule='H', kind='period').count() ... >>> df.plot() # <Axes: xlabel='2023-12-17T00:00:19+03:00'> >>> plt.show()