Simple HTTP Checker – シンプルなHTTPサーバの監視ツール

WEBサイトの死活監視のサービスはいろいろあるけれど、どれも監視できるURL数に制限があったり、制限を解除してもらうには費用がかかるわけなのだけれど求める以上の機能があってちょっと高くついたり。なかなかピッタリこない。

仕方がない、さくらのレンタルサーバが1つあるのでそこで監視するようにしてみよう。社内のサーバ監視をしているNagiosは便利なんだけれど、さくらの500円/月のレンタルサーバに入れるのはちょっとアレだし、必要最小限のスクリプトをPythonで書くことにする。

求めた要件としては、

  • HTTPで接続できなかったらメールでお知らせが届く。
  • 一回メールを送ったら、しばらくはメールしないで欲しい。
  • HEADとGETとPOSTに対応。
  • HTTPステータスコードだけをチェックする(レスポンスに含まれるコンテンツのチェックは不要)。
  • 設定ファイルで複数のURLをまとめてチェックして欲しい。
  • (さくらなので)常駐するデーモンではなくて、単独のコマンドで実行できる(cronで繰り返し実行)。
  • お知らせメールはcrontabのMAILTOで送るからstderrに出力してくれればいい。
  • OKだったときのログはstdoutに出力してくれればいい。
  • スクリプトは1ファイルで完結させる。
  • Python2.4以降、標準ライブラリだけで動く。


使い方は、
1. python htpchk.py URL
とURLを引数に指定する方法。これだと細かい設定はできませんが、HEADでチェック、お知らせメールは多くて10分に1回、となります。

2. python htpchk.py htpchk.conf
と引数に設定ファイルを指定する(あるいは引数指定を省略して、htpchk.pyと同じディレクトリにhtpchk.confという設定ファイルを置いておく)と、複数のURLを一括してチェックできるようになります。
設定ファイルは、Windowsのiniファイルに良く似た書き方です。DEFAULTセクションは全セクションのデフォルト値として使われます。あとはチェックするURL毎にセクションを作ってurlパラメータを書いておくだけで1と同様にチェックはできます。セクション毎にDEFAULTセクションの値を上書きできるので、細かい変更がしたい場合はセクションに値を書きます。詳しい設定ファイルの書き方は後の方のサンプルをご覧ください。

cronにこのスクリプトを実行するように登録しておく(さくらのレンタルサーバではこんな感じ)。
/home/example/bin/にhtpchk.pyとhtpchk.confを置いたとするとこんな感じ。

MAILTO=alert@example.com, mobile@example.com
# NAME: htpchck
*/5     *       *       *       *       /usr/local/bin/python /home/example/bin/htpchk.py >/dev/null

これで、5分に一回htpchk.confに書いてあるサイトをチェックして、HTTPステータスが200じゃなければ、標準エラーにメッセージが出力されて、そのメッセージはMAILTOに書いたメールアドレスに送られます。標準出力は/dev/nullに向けてあるので捨てられます。

手元では47のURLをチェックする設定ファイルを書いて運用中。概ね良好なんだけれど、まだチェックするURLを増やしたいのでもう少しチェックにかかる時間を短縮したい。HTTPのリクエストを送る部分をスレッドにして同時実行すれば速くなるのはわかっているので、困ったらスレッド化するかも。

以下はスクリプト本体(htpchk.py)と設定ファイル(htpchk.conf)のサンプルです。最新版はGist:343248に置いてあります。

htpchk.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""#8 Simple HTTP Checker
  Usage:
       htpchk (htpchk.conf is needed at same directory.)
       htpchk URL
       htpchk config-file
"""
__NAME__ = '#8 Simple HTTP Checker'
__VERSION__ = '1.0'
__ABOUT__ = 'http://jinim.jp/archives/2136'
__USER_AGENT__ = 'Mozilla/5.0 (compatible; %s/%s; +%s)' % (__NAME__, __VERSION__, __ABOUT__)
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"

import os
import sys
import time
import urllib2
from urllib2 import HTTPError
import urlparse
import tempfile
from ConfigParser import ConfigParser

#url to filename with sha hash.
try:
    import hashlib
    def _urlhash_filename(url): return hashlib.sha224(url).hexdigest()
except:
    import sha
    def _urlhash_filename(url): return sha.new(url).hexdigest()

opener = urllib2.build_opener()
opener.addheaders = [('User-agent', __USER_AGENT__)]
urllib2.install_opener(opener)


class ParamError(Exception): pass


class HeadRequest(urllib2.Request):
    def get_method(self): return "HEAD"


class Site(object):
    def __init__(self, name, url, notify_interval=60*10, method="HEAD", data=None, wait_seconds=0):
        """When medhod is POST, data is requred."""
        self.name = name
        self.url = url
        self.method = method
        self.data = data
        self.notify_interval = notify_interval
        self.wait_seconds = wait_seconds
        self.notified = os.path.join(tempfile.gettempdir(), "htpcheck-%s" % _urlhash_filename(self.url))

    def check(self):
        if self.method=="HEAD":
            u = urllib2.urlopen(HeadRequest(self.url))
        elif self.method=="POST":
            u = urllib2.urlopen(self.url, data=self.data)
        elif self.method=="GET":
            u = urllib2.urlopen(self.url)
        else:
            raise ParamError, "method: %s not supported. Supported methods are (HEAD,GET,POST)." % self.method
        return u.info()

    def recovered(self):
        os.rename(self.notified, os.path.join(os.path.dirname(self.notified), os.path.basename(self.notified) + time.strftime("-recovered.%Y%m%d%H%M%S")))

    def notify(self, err):
        msg = log_message(self, err)
        last_notified = 0
        if os.path.exists(self.notified):
            try:
                last_notified = time.mktime(time.strptime(file(self.notified).readlines()[-1].split("\t")[0], DATETIME_FORMAT))
            except ValueError:
                last_notified = 0
        if time.time() > (last_notified + self.notify_interval):
            sys.stderr.write(msg)
            file(self.notified, 'a').write("%s\tnotified\n" % msg[:-1])
        else:
             sys.stdout.write(log_message(self, "[SUPPRESSED] %s" % err))


def log_message(site, msg=None):
    """if msg is not None, message means error.
    """
    if msg:
        format = '%s\tNG\t%s\t%s\t%s\t%s\n'
        return format % (time.strftime(DATETIME_FORMAT), site.name, site.method, site.url, msg)
    else:
        format = '%s\tOK\t%s\t%s\t%s\n'
        return format % (time.strftime(DATETIME_FORMAT), site.name, site.method, site.url)


def config_parse(conf):
    parser = ConfigParser()
    parser.readfp(open(conf))
    sites = list()
    for section in parser.sections():
        url = parser.get(section, 'url')
        method = parser.get(section, 'method')
        if method=="POST":
            data = parser.get(section, 'data')
        else:
            data = None
        notify_interval = parser.getfloat(section, 'notify_interval')
        wait_seconds = parser.getfloat(section, 'wait_seconds')
        sites.append(Site(section, url, notify_interval, method, data, wait_seconds))
    return sites


def main(sites):
    for site in sites:
        try:
            site.check()
            sys.stdout.write(log_message(site))
            if os.path.exists(site.notified):
                site.recovered()
        except HTTPError, err:
            site.notify(err)
        if site.wait_seconds>0: time.sleep(site.wait_seconds)


if __name__=='__main__':
    if len(sys.argv)>1: config = sys.argv[1]
    else: config = os.path.splitext(__file__)[0]+'.conf'

    if not os.path.exists(config):
        if config.startswith('http://') or config.startswith('https://'):
            name = urlparse.urlparse(config)[1].split(':')[0]
            sites = (Site(name, config), )
        else:
            raise ParamError, "config file %s not found." % config
    else:
        sites = config_parse(config)

    main(sites)

htpchk.conf

[test_HEAD]
url: http://example.com/

[test_GET]
url: http://example.com/
method: GET

[test_POST]
url: http://example.com/
method: POST
data: Hello World

[test_notfound]
url: http://example.com/notfound.html
notify_interval: 10


[DEFAULT]
#************************************************
;  DEFAULT values
#************************************************

#Target URL.
#url=http://example.com/

#HTTP method
method: HEAD

#POST data
#data: Hello World

#When HTTP error occuered, repeatedly notified with interval seconds.
notify_interval: 600

#after checking, wait bellow seconds.
wait_seconds: 0