Консалтинг и автоматизация в области управления
эффективностью банковского бизнеса

Журнал ВРМ World

Из опыта создания системы анализа посещаемости коммерческого сайта (часть 2)

Подготовка данных для ClickStream Intelligence

Некоторое время назад у нашей компании появился новый коммерческий веб-сайт www.contourcomponents.com. Естественно, было интересно проанализировать статистику посещений веб-сайта, чтобы сделать выводы об эффективности рекламной кампании и определить аудиторию сайта.

Существует много способов анализа логов веб-серверов, от "tail -f" до таких богатых возможностями программ, как, например, Analog. Однако, ни один из них по каким-либо причинам не удовлетворял всех запросов. Поскольку у нашей компании есть OLAP-клиент собственной разработки - "Контур Стандарт", было решено использовать для этого именно его.

"Контур Стандарт" легко настраивается на любую базу данных, для которой существует драйвер ODBC или линк BDE. Проблема заключалась в том, что, во-первых, статистика посещений веб-сервера ведется в текстовом файле, а не в базе данных, а во-вторых, наш веб-сервер размещается у провайдера и работает под управлением операционной системы Linux (хотя операционная система не имеет большого значения).

Первая проблема решается написанием скрипта, который обрабатывает лог и копирует его в СУБД. Для второй проблемы существует два способа решения: можно вести базу прямо на веб-сервере, используя, например MySQL, и ходить к ней по TCP/IP из любого места. Но это генерирует очень большой траффик и нежелательно из-за соображений безопасности. Потому мы остановились на втором способе: периодическом копировании данных на SQL сервер, находящийся внутри локальной сети компании.

В этой статье рассказывается об одном из способов обработки логов веб-сервера и последующего копирования в СУБД, находящуюся на удаленном сервере с помощью скрипта, написанного на языке Python.

Настройка журнализации веб-сервера

Для того, чтобы иметь возможность получать интересующую информацию из логов веб-сервера, нужно сначала настроить журнализацию так, чтобы эта информация туда попадала. Веб-сервер Apache предоставляет возможность ведения любого количества журналов и определения формата каждого.

Формат журнала определяется директивой LogFormat. Полное описание этой директивы выходит за рамки этой статьи, поэтому скажу только, что в стандартном файле конфигурации httpd.conf, поставляемым с Apache, под именем combined уже определен формат лога, предоставляющий следующую информацию: ip адрес клиента, дату и время запроса, содержание запроса, код ответа сервера, количество переданных байт, адрес ссылающейся страницы, описание браузера клиента и еще пару элементов, которых мы касаться не будем. Определение формата выглядит следующим образом:


LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined

Теперь осталось определить файл лога:


CustomLog <имя_файла> combined

Если вы используете Internet Information Server, нужно на странице Web Site свойств сервера отметить опцию Enable Logging и выбрать формат лога. У формата W3C Extended Log File Format можно выбирать записываемые элементы запроса (Properties\Extended Properties).

Начинаем обработку лога

В логе каждый клиентский запрос представлен отдельной строкой, поэтому для чтения файла лога удобно воспользоваться конструкцией Python наподобие следующей:


log = open('/usr/local/apache/logs/access_log')
for request in log.readlines():
    ...


Естественно, не обязательно каждый раз обрабатывать весь файл, можно начать с первой строки, появившейся с момента последнего запуска скрипта. Для этого в конце каждой сессии нужно запоминать номер строки, например, в текстовом файле. Тогда следующую сессию можно начать с этой строки:


start = int(open('last').readline())
for request in log.readlines()[start:]:
    ...


Элементы в строке запроса разделяются пробелами. Так как в некоторых элементах могут содержаться пробелы (например, типичное содержание поля User-Agent такое: "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0)"), они берутся в кавычки. Поэтому имеет смысл сначала преобразовать строку в кортеж, используя в качестве разделителя ", а затем уже полученные элементы кортежа снова разбить на кортежи, определив разделителем пробел. Это делается методом split():


>>> request='62.110.7.82 - - [13/Sep/2002:14:19:58 +0400] "GET /demo.htm HTTP/1.1" 200 7297 "http://www.contourcomponents.com/ccactivex.htm" "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0)"'
>>> req1 = request.split('"')
>>> req1
['62.110.7.82 - - [13/Sep/2002:14:19:58 +0400] ', 'GET /demo.htm HTTP/1.1', ' 200 7297 ', 'http://www.contourcomponents.com/ccactivex.htm', ' ', 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0)', '']


В полученном кортеже нас интересуют:

  • req1[0] - ip адрес и дата (еще два элемента - ident и authuser, представленные "-" игнорируем)
  • req1[1] - запрос пользователя
  • req1[2] - код состояния, количество переданных байт
  • req1[3] - адрес страницы, с которой пришел клиент
  • req1[5] - браузер клиента

Теперь нужно вытащить нужные элементы запроса, снова применив split(), но уже с разделителем-пробелом:


>>>> #получаем ip и дату
...
>>>> req2 = req1[0].split()
>>>> req2
['62.110.7.82', '-', '-', '[13/Sep/2002:14:19:58', '+0400]']
>>>> ip = req2[0]
>>>> date = req2[3][1:]
>>>> date
'13/Sep/2002:14:19:58'
>>>> #получаем запрос клиента. Метод запроса и протокол нас не интересуют, поэтому:
...
>>>> user_request = req1[1].split()[1]
>>>> user_request
'/demo.htm'
>>>> #получаем код состояния и количество байт
...
>>>> status, bytes = req1[2].split()
>>>> status
'200'
>>>> bytes
'7297'
>>>> #получаем ссылающийся адрес и браузер
...
>>>> referer = req1[3]
>>>> browser = req1[5]

Кстати, browser можно также разделить, и получить название браузера и операционную систему клиента, а из referer извлечь, например, поисковые слова.

Очистка данных

Легко заметить, что далеко не все полученные данные подходят для загрузки на SQL сервер. Например, дата должна быть в формате, "понятном" серверу. Не говоря уже о том, что bytes и status могут и не быть числами, тогда как в базе данных для них имеет смысл завести поля типа integer (для последующего суммирования).

Разберемся сначала с датой. Определим словарь с порядковыми номерами месяцев. Он нам будет нужен в дальнейшем:


month = {'Jan' : 1, 'Feb' : 2, 'Mar' : 3, 'Apr' : 4, 'May' : 5, 'Jun' : 6, 'Jul' : 7, 'Aug' : 8, 'Sep' : 9, 'Oct' : 10, 'Nov' : 11, 'Dec' : 12}

>>>> #разделяем на день, часы, минуты, секунды
...
>>>> datetime = date[1:].split(':')
>>>> datetime
['13/Sep/2002', '14', '19', '58']

В принципе, день можно оставить как есть. MS SQL Server понимает такой формат. Тогда окончательная дата будет:


>>>> sql_date = '%s %s:%s:%s' % (datetime[0], datetime[1], datetime[2], datetime[3])
>>>> sql_date
'13/Sep/2002 14:19:58'

Можно определить другой формат, например:


>>>> #получаем день, месяц, год
...
>>>> day = datetime[0].split('/')
>>>> day
['13', 'Sep', '2002']
>>>> #теперь конструируем дату
...
>>>> sql_date = '%s-%s-%s %s:%s:%s' % (day[2], month[day[1]], day[0], datetime[1], datetime[2], datet
ime[3])
>>>> sql_date
'2002-9-13 14:19:58'

Теперь проверим, действительно ли bytes и status содержат целые числа. Это лучше всего сделать с помощью конструкции try/except:


try:
    bytes = int(bytes)
except:
    bytes = 0

Еще одна проблема может возникнуть с содержанием запроса клиента. Дело в том, что строка запроса может быть очень длинной (например, при попытке взлома веб-сервера злоумышленник может сформировать такую строку, пытаясь воспользоваться уязвимостью переполнения буфера). Имеет смысл ограничить ее разумной величиной, например 256 символов:


if len(user_request) > 255:
    user_request = user_request[:255]

Конечно, если вы хотите анализировать попытки взлома и некорректные запросы, нужно оставить все как есть, задав соответствующему полю таблицы нужный формат.

Определение имени хоста

Если в httpd.conf включено определение имен хостов (HostNameLookups on), в лог записываются не ip адреса клиентов, а имена хостов. Включать определение имен хостов не рекомендуется, так как ухудшает производительность веб-сервера. Гораздо лучше определять имена хостов клиентов (если это требуется для анализа) при обработке логов.

Для определения имени хоста по ip адресу модуле socket есть функция gethostbyaddr(). Она возвращает кортеж, состоящий из основного имени хоста, списка дополнительных имен и списка дополнительных ip адресов.


>>>> import socket
>>>> host = socket.gethostbyaddr('194.109.137.226')
>>>> host
('fang.python.org', [], ['194.109.137.226'])

Если узнать имя хоста не удалось, возбуждается исключение:


>>>> socket.gethostbyaddr('172.16.0.1')
Traceback (most recent call last):
..File "<stdin>", line 1, in ?
socket.herror: (11004, 'host not found')
>>>> 

Так как в логе обычно присутствует не один запрос с данного ip адреса, нет смысла каждый раз определять имя хоста с помощью вызова функции gethostbyaddr(). Можно заполнять словарь определенными соответствиями и сначала пробовать искать в нем, а уже потом вызывать функцию. А так как имена хостов меняются редко, можно завести текстовый файл (или базу данных) и хранить информацию в нем. Тогда имена хостов, определенные во время сессии, используются в последующих сессиях, что сильно уменьшает время обработки. Неудачные попытки определения имени также имеет смысл записывать в словарь, чтобы во время текущей сессии больше не пытаться распознать данный адрес. Суммируя все выше сказанное, приведем фрагмент скрипта для определения имени хоста:


import socket

dns = {}
bad_ip = {}

# открываем файл с именами хостов для добавления
names = open('names.txt', 'a+')

# заполняем словарь уже известными именами
for i in names.readlines():
    dns[i.split()[0]] = i.split()[1]

if dns.has_key(ip):
    # если адрес присутствует в словаре, присваиваем найденное имя
    host = dns[ip]
elif bad_ip.has_key(ip):
    # если ранее не удалось определить имя - используем ip
    host = bad_ip[ip]
else:
    try:
        # пытаемся определить имя
        host = socket.gethostbyaddr(ip)[0]
        # записываем его в словарь и в файл
        dns[ip] = host
        names.write('%s %s\n' % (ip, host))
    except:
        # определить имя не удалось - используем ip
        # и записываем его в словарь неопределяемых ip адресов
        host = ip
        bad_ip[ip] = ip


Примечание: словарь "неопределяемых" адресов ведется только в течении сессии и в файл не записывается (а вдруг в следующий раз повезет?). Если имя определить не удалось, в качестве имени берется ip адрес.

Определение страны по ip адресу

Следующим шагом неплохо было бы определить страну клиента. Конечно, это можно сделать по домену первого уровня, взятого из имени хоста, но, во-первых, физическое расположение хоста не всегда соответствует стране, которой приписан домен (например, хост зоны .com может находиться и в России), к тому же есть такие интернациональные домены как .org, .net, .edu и т.д.

Проблему можно разрешить, получив статистику о распределении адресного пространства от одной из организаций, ответственной за это (APNIC (http://www.apnic.net/), RIPE NCC(http://www.ripe.net/), ARIN (http://www.arin.net/)). Эти организации с определенной периодичностью размещают такую статистику на своих FTP серверах. Для получения этой информации и формирования базы распределения ip адресов по странам, было решено воспользовался кодом, описанным в статье Д.Откидача "Определение страны по IP адресу" (http://python.ru/2002-06/69.html). Приведенный в статье код распространяется под лицензией в стиле Python и состоит из определения двух классов IPRangeDB и его наследника CountryByIP, в котором определены методы заполнения базы. Для удобства я поместил описания этих классов в отдельный модуль country.py, в конце которого добавил пару строчек:


if __name__ == "__main__":
    db = CountryByIP('country.db', 'n')
    db.fetch()

Таким образом, класс IPRangeDB можно импортировать из основного модуля и использовать для определения страны по ip адресу, а если запустить country.py, будет обновляться база соответствия ip адресов странам. Хороший вариант - периодически автоматически запускать скрипт (через cron в Linux или Task Sheduler в Windows).

В основном скрипте для определения страны пишем следующий код:


db = IPRangeDB('country.db')
undefined_country = {}

if not undefined_country.has_key(ip):
    try:
        country = db[ip]
    except KeyError:
        print 'Не удалось определить страну для: %s' % ip
        undefined_country[ip] = '00'
        country = '00'
else:
    country = '00'

Ip адреса, для которых не удалось определить страну, выводятся на экран и добавляются в словарь undefined_country. При этом стране присваивается код "00". Вывод на экран можно продублировать выводом в отдельный файл для последующего анализа.

Пересылка результатов обработки по FTP

Итак, мы обработали клиентский запрос и в результате получили несколько переменных с интересующей нас информацией.

Удобно записать эти переменные в какой-нибудь файл, который затем легко можно будет обработать, например в csv. Далее его нужно заархивировать для уменьшения траффика и передать по FTP на наш локальный SQL сервер для последующей обработки. Для записи строки в csv файл добавляем:

csv.write('%s;%s;%s;%s;%s;%s;%s;%s;%s;%s\n' % (ip, host, date, time, request, status, bytes, referer, browser, country))

(подразумевается, что csv-файловый объект, открытый на запись)

Для копирования файла по протоколу FTP используем модуль ftplib. Так как объем данных может быть очень большим, заархивируем файл:

import os

command = 'bzip2 -z log.csv'
os.system(command)

Затем создаем соединение с FTP сервером и передаем файл:

ftp = FTP('ftp.myhost.ru', 'oartemov', 'secret')
ftp.set_pasv(1)
ftp.cwd('/export')
ftp_command = 'STOR log.csv.bz2'
ftp.storbinary(ftp_command, open('log.csv.bz2'))
ftp.quit()


Удаляем ненужный архив:


os.unlink('log.csv.bz2')

В примере описывается достаточно экзотический случай, когда FTP сервер находится внутри локальной сети, а веб-сервер, стоящий у провайдера, выступает в роли клиента. Обычно же FTP сервер установлен на том же компьютере, что и веб-сервер, поэтому скрипт необходимо запускать с компьютера, находящегося в локальной сети и вместо метода storbinary() использовать retrbinary(). Можно код, ответственный за получение данных по FTP, разместить вначале скрипта, помещающего данные в СУБД.

Полезные ссылки:

Определение страны по IP адресу:
http://python.ru/2002-06/69.html

Текстовая обработка в языке Python. Подсказки для начинающих

Готовя на Python. Семь изысканных рецептов для программистов