Apacheのアクセス ログをPythonで解析するには?

Apacheログを効率よく解析するのは、SEO対策の面でも、パフォーマンス チューニングの面でも、かなり有効です。Apacheで一般的に使われるのはcommonとcombined形式のアクセスログで、かつcombinedio形式を独自にカスタマイズしたものなども使われます。とりあえず、一般的なcommon形式とcombined形式を正規表現化してみましょう。

  • commonの場合、

    ^([0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}) ([^ ]{1,}) ([^ ]{1,}|\-) \[([0-9]{2}\/[A-Za-z]{3}\/[0-9]{1,4}:[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+\-][0-9]{4})\] "([A-Z ]+) ([^"]*) ([^"]*)" ([0-9]{3}) ([0-9]{1,}|\-) "([^"]*|\-)" "([^"]+)"$
  • combinedの場合、

    ^([0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}) ([^ ]{1,}) ([^ ]{1,}|\-) \[([0-9]{2}\/[A-Za-z]{3}\/[0-9]{1,4}:[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+\-][0-9]{4})\] "([A-Z ]+) ([^"]*) ([^"]*)" ([0-9]{3}) ([0-9]{1,}|\-) "([^"]*|\-)" "([^"]+)"$
といった感じになります。がしかし、このままではあまりに融通が利かないので、Pythonで次のようなLogLineクラス(文字列を分析するファクトリ)とLogRowクラス(ログ要素に解析済みのオブジェクト)、FORMATクラス(定数を入れるためだけ用)を作ってみました。


import re#スクリプト ファイルの冒頭部分に記述するのがよい

class FORMAT(object):
_UNSET_ = "unset"
_UNDEFINED_ = "undefined"
_UNKNOWN_ = "unknown"
_COMMON_ = "common"
_COMBINED_ = "combined"

class logRow(object):
ipaddr = ""
clientID = ""
userID = ""
requestTimeStr = ""
requestTimeTzStr = ""
requestTimeTuple = False
method = ""
resource = ""
protocol = ""
statusCode = ""
responseBodySize = 0L
referer = ""
userAgent = ""
options = {}
def __init__(self, cols, **kwargs):
if isinstance(cols, list) or isinstance(cols, tuple):
if len(cols)>8:
self.ipaddr = cols[0]
self.clientID = cols[1]
self.userID = cols[2]
self.requestTimeStr = cols[3]
self.requestTimeTzStr = self.requestTimeStr[-5:]
self.requestTimeTuple = time.strptime(cols[3][:-6], "%d/%b/%Y:%H:%M:%S")
self.method = cols[4]
self.resource = cols[5]
self.protocol = cols[6]
self.statusCode = cols[7]
try:
self.responseBodySize = long(cols[8])
except ValueError:
self.responseBodySize = 0L
if len(cols)>10:
self.referer = cols[9]
self.userAgent = cols[10]
if len(cols)==12:
if isinstance(cols[11], dict):
self.options = cols[11]
else:
self.options = kwargs

def __dict__(self):
resDict = {}
resDict['ipaddr'] = self.ipaddr
resDict['clientID'] = self.clientID
resDict['userID'] = self.userID
resDict['requestTimeStr'] = self.requestTimeStr
resDict['requestTimeTzStr'] = self.requestTimeTzStr
resDict['requestTimeTuple'] = self.requestTimeTuple
resDict['method'] = self.method
resDict['resource'] = self.resource
resDict['protocol'] = self.protocol
resDict['statusCode'] = self.statusCode
resDict['responseBodySize'] =self.responseBodySize
resDict['referer'] = self.referer
resDict['userAgent'] = self.userAgent
resDict['options'] = self.options
return resDict

def __repr(self):
return repr(self.__dict__)

class LogLine(object):
"""
Apache log parser class. Parsed field is set on field attribute.
Parsed field is the instance of structure class named as logRow.
"""
_FMT_COMMON = re.compile("""^([0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}) ([^ ]{1,}) ([^ ]{1,}|\-) \[([0-9]{2}\/[A-Za-z]{3}\/[0-9]{1,4}:[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+\-][0-9]{4})\] "([A-Z ]+) ([^"]*) ([^"]*)" ([0-9]{3}) ([0-9]{1,}|\-) "([^"]*|\-)" "([^"]+)"$""")
_FMT_COMBINED = re.compile("""^([0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}\.[0-9]{,3}) ([^ ]{1,}) ([^ ]{1,}|\-) \[([0-9]{2}\/[A-Za-z]{3}\/[0-9]{1,4}:[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2} [+\-][0-9]{4})\] "([A-Z ]+) ([^"]*) ([^"]*)" ([0-9]{3}) ([0-9]{1,}|\-) "([^"]*|\-)" "([^"]+)"$""")
_format = FORMAT._UNSET_
_rawString = ""
__FORMATS__ = {}
field = False
def __init__(self, line="", format=None):
"""

"""
self.__FORMATS__[FORMAT._COMMON_] = self._FMT_COMMON
self.__FORMATS__[FORMAT._COMBINED_] = self._FMT_COMBINED
if isinstance(line, basestring):
self._format = self.judgeFormat(line, format)
self._rawString = line
else:
self._format = FORMAT._UNSET_
if self._rawString!="" and self._format!=FORMAT._UNSET_ and self._format!=FORMAT._UNKNOWN_ and self._format!=FORMAT._UNKNOWN_:
self.parseLine()

@classmethod
def judgeFormat(clsObj, line="", format=""):
if format is None:
if clsObj._FMT_COMBINED.match(line):
format = FORMAT._COMBINED_
elif clsObj._FMT_COMMON.match(line):
format = FORMAT._COMMON_
else:
format = FORMAT._UNDEFINED_
if format=="common":
format = FORMAT._COMMON_
elif format=="combined":
format = FORMAT._COMBINED_
else:
format = FORMAT._UNDEFINED_
return format

def parseLine(self, rawLine=None):
if rawLine is None and self._format in self.__FORMATS__.keys() and self._rawString!="":
parsedTuple = self.__FORMATS__[self._format].findall(self._rawString)
if parsedTuple:
self.field = logRow(parsedTuple[0])
return True
elif rawLine is not None and isinstance(rawLine, basestring):
self._format = self.judgeFormat(rawLine)
result = self.parseLine()
return result
else:
return False

def getFormat(self):
return self._format

def setFormat(self, format):
if isinstance(format, basestring):
if format=="common":
self._format = FORMAT._COMMON_
elif format=="combined":
self._format = FORMAT._COMBINED_

def getRawString(self):
return self._rawString

def setRawString(self, line=None):
if isisntance(line, basestring):
self._rawString = line

LogLineクラスのコンストラクタにApacheのログ行(文字列)を食わせると、LogLineインスタンスのfieldアトリビュートに、解析されたログ行のキャプチャできた部分がLogRowクラス インスタンスとして保存されます。その際に、Apacheのアクセス ログ形式がcommonとcombined形式のどちらかでありさえすれば、行ごとに自動解析する仕組みにしました。

たとえば、


210.155.149.140 - - [06/Apr/2009:22:11:19 +0900] "POST /wordpress/wp-cron.php?check=83d0c18ebeedfebf737edad33a3142f1 HTTP/1.0" 200 - "-" "WordPress/2.7.1"
といったアクセス ログ行があった場合、fieldアトリビュート
  • ['requestTimeStr'] : 06/Apr/2009:22:11:19 +0900

  • ['resource'] : /wordpress/wp-cron.php?check=83d0c18ebeedfebf737edad33a3142f1

  • ['ipaddr'] : 210.155.149.140

  • ['responseBodySize'] : 0

  • ['userID'] : -

  • ['clientID'] : -

  • ['requestTimeTzStr'] : +0900

  • ['options'] : {}

  • ['referer'] : -

  • ['userAgent'] : WordPress/2.7.1

  • ['protocol'] : HTTP/1.0

  • ['requestTimeTuple'] : (2009, 4, 6, 22, 11, 19, 0, 96, -1)

  • ['method'] : POST

  • ['statusCode'] : 200

というように解析されます。そのままDBに入れるもヨシ、LogRowインスタンスだけを配列系統にまとめて分析にかけるもよし、という具合がよいかと思います。

Pythonコードをよく読まれた方は気付かれると思うのですが、LogLineクラスの_FMT_*アトリビュートに任意の正規表現オブジェクトを追加し、FORMATクラスの定数定義を併せて拡張することで、アクセス ログの解析方法をカスタマイズできます(継承してもいいし、継承しにくければ、インスタンス化してから直接属性追加してもいいかと)。

ということで、Python関連のテクニックやら発見したことなどを、今後とも不定期で書いていこうと思います。