はじめまして。技術開発室の與島( @shiimaxx )です。

先日、mackerelio/go-check-pluginsにSMTP接続の正常性を監視するプラグインcheck-smtpを追加するPull Requestを送り、Mergeしていただきました。 そこで、本記事ではcheck-smtpの概要、新規プラグイン開発をするにあたってどのような点を意識したか、について紹介します。

なお、チェックプラグインの作成方法自体については説明しません。作成方法については、以下のヘルプが参考になりますのでご参照ください。

また、プラグインの開発時のGoのバージョンは1.10です。

背景

ハートビーツでは、NagiosとNagios Pluginsを使って監視をしています(その他、監視の要件によっては自作Nagiosプラグインも使っています)。 Nagios PluginsはNagios公式のプラグイン集であり、標準的な監視項目に必要なひととおりのプラグインが含まれています。

弊社でも大変重宝していますが、YumやAPTなどのパッケージマネージャー経由でインストールするときにパッケージ依存によって意図しないアップデートやインストールが発生することがあり、ときどき問題になっていました。 そんなこともあり、最近はOSやシステムの依存関係から切り離してインストールできるGo製のgo-check-pluginsを利用する方向にシフトしてます。現在は、ハートビーツにおけるユースケースを満たすために機能追加をして、都度Pull Requestで送らさせていただている段階です。check-smtpの追加もその取り組みの一環として実施しました。

check-smtp

監視対象のSMTPサーバを指定して、動作の正常性とレスポンスタイムを監視できます。 接続方法は、SMTP(デフォルト)、SMTP over TLS、STARTTLSが選択できます。また、現状はPLAIN認証のみですが、SMTP認証にも対応しています。

# SMTP
check-smtp -H smtp.example.com -p 25 -w 3 -c 5 -t 10

# SMTP over TLS
check-smtp -H smtp.example.com -p 465 -s -w 3 -c 5 -t 10

# STARTTLS
check-smtp -H smtp.example.com -p 587 -S -w 3 -c 5 -t 10

# SMTP AUTH
check-smtp -H smtp.example.com -p 587 -A PLAIN -S -U username -P password -w 3 -c 5 -t 10

GitHubのソースコードは mackerelio/go-check-plugins - check-smtp です。

check-smtpのチェック方法

ここからは、実装方法やコードの中身について少しお話したいと思います。

まず、SMTP接続の正常性のチェック方法は、Nagios Pluginsに含まれているcheck_smtpの実装を参考にしました。check_smtpでは、次の流れでSMTP接続のチェックをしています。

  1. TCPコネクションを確立
  2. HELOコマンド(オプションでEHLO, LHLOも選択可能)
  3. STARTTLSコマンド(オプション)
  4. AUTHコマンド(オプション)
  5. MAILコマンド
  6. QUITコマンド

今回作成したcheck-smtpでも、大体同じようにチェックをするようにしました。 QUITコマンドの前にRSETコマンドを実行しているのは、社内レビューの際にもらった「MAIL FROMから始まったトランザクションを中断してからセッションを終了するほうが好ましい」というアドバイスを取り入れたためです。

  1. TCPコネクションを確立
  2. HELOコマンド
  3. STARTTLSコマンド(オプション)
  4. AUTHコマンド(オプション)
  5. MAILコマンド
  6. RSETコマンド
  7. QUITコマンド

テストコード

閾値とタイムアウトのオプションの挙動をテストしています。

  1. warning, critical, timeout オプションで指定した値が意図どおりに反映されているか
  2. warning, critical オプションがどちらも指定されていない場合に意図したステータスが返るか

1のテストケースの結果はチェック対象のSMTPサーバに依存するため、SMTPプロトコルに沿ってレスポンスを返すだけのモックサーバを作りました。この方法は、標準パッケージである"net/smtp"のテストコードを一部参考にしています。

type mockSMTPServer struct {
    listener  net.Listener
    delay     int
    responses []string
}

func (m *mockSMTPServer) runServe() error {
    doneCh := make(chan struct{})
    errCh := make(chan error)
    go func() {
        time.Sleep(time.Duration(m.delay) * time.Second)

        conn, err := m.listener.Accept()
        if err != nil {
            errCh <- err
            return
        }
        tc := textproto.NewConn(conn)

        for _, res := range m.responses {
            if err := tc.PrintfLine(res); err != nil {
                errCh <- err
                return
            }
        }
        doneCh <- struct{}{}
    }()

    select {
    case <-doneCh:
        return nil
    case err := <-errCh:
        return err
    }
}

mockSMTPServerresponsesフィールドに設定された[]string型の要素を順番にレスポンスとして返します。 responseフィールドはテストケースごとに設定できますが、今回のケースでは以下のresponseDefaultのみで十分でした。 STARTTLSやSMTP AUTHのテストケースを追加する場合は、それに対応するSMTPサーバ側のレスポンスを要素にした、新たな[]string型の変数を定義する必要があります。

var responseDefault = []string{
    "220 mail.example.com ESMTP unknown",
    "250 mail.example.com",
    "250 OK",
    "250 OK",
    "221 Bye",
}

オプション用のstructを定義するときの注意点

go-check-pluginsでは、コマンドライン引数のパースにjessevdk/go-flagsを使います。このライブラリでは、READMEのExampleにあるとおり、オプションをstructとして定義します。今回のケースでは定義の仕方に問題があり、一部のテストケースが意図した結果にならないということがありました。

最初は、具体的な処理を書いているrun()の外で次のように変数として定義していました。

var opts struct {
    Host     string  `short:"H" long:"host" default:"localhost" description:"Hostname"`
    Port     string  `short:"p" long:"port" default:"25" description:"Port"`
    FQDN     string  `short:"F" long:"fqdn" description:"FQDN used for HELO"`
    SMTPS    bool    `short:"s" long:"smtps" description:"Use SMTP over TLS"`
    StartTLS bool    `short:"S" long:"starttls" description:"Use STARTTLS"`
    Auth     string  `short:"A" long:"authmech" description:"SMTP AUTH Authentication Mechanisms (only PLAIN supported)"`
    User     string  `short:"U" long:"authuser" description:"SMTP AUTH username"`
    Password string  `short:"P" long:"authpassword" description:"SMTP AUTH password"`
    Warning  float64 `short:"w" long:"warning" description:"Warning threshold (sec)"`
    Critical float64 `short:"c" long:"critical" description:"Critical threshold (sec)"`
    Timeout  int     `short:"t" long:"timeout" default:"10" description:"Timeout (sec)"`
}

オプションのパースはrun()の中で行います。具体的には、run()の外で定義したoptsのポインタをflags.ParseArgs()の第1引数に渡します。このとき、実際に与えられたコマンドライン引数をもとにoptsのフィールドが直接書き換えられます。 そのため、複数のテストケースを実行するときに、前のテストケースで書き換えられたoptsが次のテストケースでも使いまわされるため、これにより テスト結果が意図しないものになる可能性があります。

func run(args []string) *checkers.Checker {
    _, err := flags.ParseArgs(&opts, args)

次のようにtype optionsで型としてstructを定義しておき、run()の中でoption型の変数を定義する方法に変更しました。これで、テストケースが複数ある場合でもrun()を呼び出すごとにoptsが初期化されるため、意図どおりのオプションでテストが実行できます。

type options struct {
    Host     string  `short:"H" long:"host" default:"localhost" description:"Hostname"`
    Port     string  `short:"p" long:"port" default:"25" description:"Port"`
    FQDN     string  `short:"F" long:"fqdn" description:"FQDN used for HELO"`
    SMTPS    bool    `short:"s" long:"smtps" description:"Use SMTP over TLS"`
    StartTLS bool    `short:"S" long:"starttls" description:"Use STARTTLS"`
    Auth     string  `short:"A" long:"authmech" description:"SMTP AUTH Authentication Mechanisms (only PLAIN supported)"`
    User     string  `short:"U" long:"authuser" description:"SMTP AUTH username"`
    Password string  `short:"P" long:"authpassword" description:"SMTP AUTH password"`
    Warning  float64 `short:"w" long:"warning" description:"Warning threshold (sec)"`
    Critical float64 `short:"c" long:"critical" description:"Critical threshold (sec)"`
    Timeout  int     `short:"t" long:"timeout" default:"10" description:"Timeout (sec)"`
}

func run(args []string) *checkers.Checker {
    var opts options
    _, err := flags.ParseArgs(&opts, args)
    :

まとめ

同じくgo-check-pluginsのcheck-tcpでもSMTP接続のチェックが行えますが、L7の挙動まで踏み込んだ監視をしたいなど、check-smtpがマッチする場合もあるかと思いますので、ぜひご活用ください。

また、今後も引き続き、機能追加、改修など、コントリビュートしていければと思っています。

株式会社ハートビーツのインフラエンジニアから、ちょっとした情報をお届けします。