Сообщить об ошибке.

Анализ логов Nginx модулем pandas в Python

Материал представляет собой мысли о том как можно подойти к анализу логов Nginx при помощи библиотеки pandas.

Подготовка данных Nginx

Настройка формата журнала логов Nginx

Первое, что нужно сделать - это настроить разделители полей создаваемого сервером 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

Смотрим и считаем коды ответа 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']]

Cчитаем количество посещений с одинаковыми IP-адресами

# сохраним полученный `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

Количество одинаковых ip с определенной ошибкой

# Ошибка 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-адреса, у которых количество посещений больше 10

>>> 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")

Смотрим все адреса сайта, куда ходил определенный IP и какой у него 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

Выбираем 50 наиболее посещаемых URL-адресов:

>>> 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()