Nagios のパフォーマンスデータを Parsec で解析してみた

Nagios にはチェックデータを取得した際に、パフォーマンスデータを一緒に付け加えてくれるという機能があり、以下のページにあるツールはこのパフォーマンスデータを使って CPU 使用率やディスク空き容量をグラフ化したりといったことをしています。

で、このパフォーマンスデータがどういう文字列になっているのかというと、

time=0.166938s;;;0.000000 size=32954B;;;0

だったり、

rta=500.000000ms;100.000000;500.000000;0.000000 pl=100%;20;60;0

だったりです。ここでは、このフォーマットを Parsec で解析することを目標にします*1


このパフォーマンスデータの構文は各ホストやサービスのチェック用プラグインが付け加えるものなのですが、以下のページにはそのデータの構文をどうするべきかについてのガイドラインが示されています。

簡単に説明すると、

'label'=value[UOM];[warn];[crit];[min];[max]

  • それぞれのパフォーマンスデータは label=value という key/value ペアで表現されていて、各パフォーマンスデータはスペースで区切られる
  • ラベルは任意の文字が使える
  • ラベルに空白やイコールやシングルクォートを使いたい場合はラベルをシングルクォートで囲み、さらにシングルクォートを入力するにはシングルクォートを 2 つ連続で書く
    • 例: 'ho''ge' ← ho'ge という文字列になる
  • UOM 以降のデータはオプショナルである。その際セミコロンは省略してもいい。
    • hoge=10 ← セミコロン省略可能
    • hoge=10;;;; ← 別にこうしても構わない
  • value と min と max のデータは [-0123456789.] のどれかの文字の連続である
    • ↑かなり曖昧なので後で細かく書く
  • warn と crit のデータは 2.5. Threshold and ranges に書かれている通りのフォーマットである
    • 例を挙げるとこんな感じ
書き方 意味
10 0 <= x <= 10
10: 10 <= x
~:10 x <= 10
10:20 10 <= x <= 20
@10:20 10 < x < 20
  • UOM は以下のどれか
    • s, us, ms (秒、マイクロ秒、ミリ秒)
    • % (パーセント)
    • B, KB, MB, TB (バイト、キロバイト、メガバイト、テラバイト)
    • c (継続カウンタ?)

見ての通りかなり曖昧な文法*2なので、適当に解釈しつつ BNF(厳密な BNF ではない)を作ることにします。

// クォートされていないときに使える文字
<anychar'> ::= any characters except one of " ='\r\n"
// クォートされているときに使える文字
<anychar> ::= any characters except one of "'\r\n"
<label> ::= "'" (<anychar>|"''")+ "'" | <anychar'>+

// -10.00 や -.01 といった文法は許可する
// でも 10. という書き方は許可しない
<number> ::= '-'? (<0-9>* '.' <0-9>+) | <0-9>+
<value> ::= <number>
<min> ::= <number>
<max> ::= <number>

// 数字部分のフォーマットは <number> のフォーマットに従う
<range> ::= '@'? (<number> |
                  (<number>|'~') ':' <number>?)
<warn> ::= <range>
<crit> ::= <range>

<uom> ::= 's' | 'us' | 'ms' |
          '%' |
          'B' | 'KB' | 'MB' | 'TB' |
          'c'

// 後ろを省略できることを示しながら書くとこうなったっていう・・・。
<perfdata> ::= <label>=<value>(<uom>?(;(<warn>?(;(<crit>?(;(<min>?(;<max>?)?)?)?)?)?)?)?)?

// それぞれのデータはスペースで区切られるし、
// 前後にスペースが入っていても構わない
<perfdatalist> ::= ' '* (<perfdata> ' '+)* <perfdata> ' '*

ということで、これらの BNF を Parsec に落としてみるとこうなりました。

p_label = unquoted <|> quoted
  where
    anychar' = noneOf " ='\r\n"
    anychar  = noneOf "'\r\n"
    unquoted = many1 anychar'
    quoted   = between (char '\'') (char '\'') $
                       many1 $ anychar <|>
                               try (string "''" >> return '\'')

ラベルはクォートされている場合とされていない場合で分けて、あと '' でシングルクォートになる処理はバックトラックが必要になるので try を使ってます。

-- <number> ::= <n0>
-- <n0> ::= '-'? <n1>
-- <n1> ::= <0-9>+ <n2>? | <n2>
-- <n2> ::= '.' <0-9>+
p_number = n0 >>= return.toRational
  where
    toRational str = readPair $ break (=='.') str
      where
        readPair (int,(_:frac)) = read (int++frac) % (10^length frac)
        readPair (int,_) = read int % 1
    n0 = liftA2 (++) (option "" (string "-")) n1
    n1 = liftA2 (++) (many1 digit) (option "" n2) <|> n2
    n2 = liftA2 (:) (char '.') (many1 digit)

は Rational で返します。こうすることで何桁のデータであっても表現することができます。

data Range = Range {
  rangeInclusive :: Bool,
  rangeStart :: Maybe Rational,
  rangeEnd :: Maybe Rational
} deriving (Show,Eq)

isInRange x (Range inclusive start end) = range x inclusive start end
  where
    range x True start end = comp (>=) x start && comp (<=) x end
    range x False start end = comp (>) x start && comp (<) x end
    comp cp x (Just y) = cp x y
    comp cp x Nothing = True

-- <range> ::= '@'? (<r1> | <r2>)
-- <r1> ::= <number> <r3>?
-- <r2> ::= '~' <r3>
-- <r3> ::= ':' <number>?
p_range = do
    inclusive <- option True (char '@' >> return False)
    (start,end) <- try r1 <|> r2
    return $ Range inclusive start end
  where
    r1 :: GenParser Char st (Maybe Rational,Maybe Rational)
    r1 = do
         v <- p_number
         option (Just (0%1),Just v) (r3 >>= return.(Just v,))
    r2 :: GenParser Char st (Maybe Rational,Maybe Rational)
    r2 = char '~' >> r3 >>= return.(Nothing,)
    r3 :: GenParser Char st (Maybe Rational)
    r3 = do
         char ':'
         optionMaybe p_number

は Range 型を作って返します。ある値がこの Range の範囲内に入ってるかどうかは isInRange 関数を使って判断します。

p_uom =
  string "s" <|> try (string "us") <|> try (string "ms") <|>
  string "%" <|>
  string "B" <|> try (string "KB") <|> try (string "MB") <|> try (string "TB") <|>
  string "c"

これは一文字目を見ればどの単位なのか分かるから、ほんとは try を使わずに書くこともできるのだけど、汚くなるのでやってない。
あと今はこれを文字列で返しているのだけれども、もしかしたら新しく data 型を作った方がいいかも。

data PerfData = PerfData {
  pfLabel :: String,
  pfValue :: Rational,
  pfUom :: Maybe String,
  pfWarning :: Maybe Range,
  pfCritical :: Maybe Range,
  pfMin :: Maybe Rational,
  pfMax :: Maybe Rational
} deriving (Show,Eq)

p_perfdata = p0
  where
    end = (char ' ' >> return ()) <|> eof
    endNothings5 = endNothings4 >>= return.(Nothing,)
    endNothings4 = endNothings3 >>= return.(Nothing,)
    endNothings3 = endNothings2 >>= return.(Nothing,)
    endNothings2 = endNothings1 >>= return.(Nothing,)
    endNothings1 = return Nothing
    semiOrValue p = (char ';' >> return Nothing) <|>
                    (p >>= (optionMaybe (char ';') >>) . return . Just)
    p0 = do
         label <- p_label
         char '='
         value <- p_number
         (uom,(warn,(crit,(min,max)))) <- p1
         return $ PerfData label value uom warn crit min max
    p1 = (end >> endNothings5)
         <|> do
             uom <- semiOrValue p_uom
             p2 >>= return.(uom,)
    p2 = (end >> endNothings4)
         <|> do
             warn <- semiOrValue p_range
             p3 >>= return.(warn,)
    p3 = (end >> endNothings3)
         <|> do
             crit <- semiOrValue p_range
             p4 >>= return.(crit,)
    p4 = (end >> endNothings2)
         <|> do
             min <- semiOrValue p_number
             p5 >>= return.(min,)
    p5 = (end >> endNothings1)
         <|> (p_number >>= return.Just)

ついにパフォーマンスデータの解析ができるようになりました。
大分ごちゃごちゃしてるのですが、値の遷移を書くとこんな感じ。

p_perfdatalist = do
  many (char ' ')
  many1 $ do
          pd <- p_perfdata
          many (char ' ')
          return pd

あとはパフォーマンスデータのリストを取得するだけ。パフォーマンスデータを解析し終わった時点で、区切り文字のスペースは食われてるので、BNF とはちょっと違う感じに。


とまあこんなコードを書いて、最初の例の文字列

time=0.166938s;;;0.000000 size=32954B;;;0

をパースして print してやると

[PerfData {
  pfLabel    = "time",
  pfValue    = 83469 % 500000,
  pfUom      = Just "s",
  pfWarning  = Nothing,
  pfCritical = Nothing,
  pfMin      = Just (0 % 1),
  pfMax      = Nothing },
 PerfData {
  pfLabel    = "size",
  pfValue    = 32954 % 1,
  pfUom      = Just "B",
  pfWarning  = Nothing,
  pfCritical = Nothing,
  pfMin      = Just (0 % 1),
  pfMax      = Nothing }]

と出力されて、

rta=500.000000ms;100.000000;500.000000;0.000000 pl=100%;20;60;0

をパースすると

[PerfData {
  pfLabel    = "rta",
  pfValue    = 500 % 1,
  pfUom      = Just "ms",
  pfWarning  = Just (Range {
                 rangeInclusive = True,
                 rangeStart = Just (0 % 1),
                 rangeEnd = Just (100 % 1) }),
  pfCritical = Just (Range {
                 rangeInclusive = True,
                 rangeStart = Just (0 % 1),
                 rangeEnd = Just (500 % 1) }),
  pfMin      = Just (0 % 1),
  pfMax      = Nothing },
 PerfData {
  pfLabel    = "pl",
  pfValue    = 100 % 1,
  pfUom      = Just "%",
  pfWarning  = Just (Range {
                 rangeInclusive = True,
                 rangeStart = Just (0 % 1),
                 rangeEnd = Just (20 % 1) }),
  pfCritical = Just (Range {
                 rangeInclusive = True,
                 rangeStart = Just (0 % 1),
                 rangeEnd = Just (60 % 1) }),
  pfMin      = Just (0 % 1),
  pfMax      = Nothing }]

と出力されて(それぞれ手動整形済み)、ちゃんと解析できていることが分かります。

*1:上記の2つのツールは C で適当な解析してたり Perl で厳密でない正規表現の解析してたりするので、ここでちゃんと文法を書いておくのは無駄なことではないはず

*2:label で使う任意のキャラクタってどの文字だよとか、value,min,max の数字の並び方ってどんな風にすればいいんだよとか、warn,crit の数字部分で許可されているフォーマットってどんなのがあるんだよとか