Go Conference 2014 Autumnレポート

3セッションについてのまとめ~「App Engine for Golang Performance」「Golang @ISUCON」「mackerel-agent徹底解説」~

この記事を読むのに必要な時間:およそ 7.5 分

mackerel-agent徹底解説(@songmu氏)

このセッションでは,@songmu氏から,はてなが提供するMackerelで使用されている,mackerel-agentというGo言語で書かれたプログラムについて解説がされました。なお,このセッションの資料は以下のリンクから閲覧できます。

写真3 @songmu氏の発表の様子

写真3 @songmu氏の発表の様子

Mackerel

Mackerelとは,はてなが運営する,サーバ監視・管理ツールを提供するサービスであると同氏は説明していました。管理対象のサーバから送信されてくるメトリクスを集計し,モニタリングやアラートの送信,グラフの描画などができるそうです。元は社内ツールだったそうで,それをスクラッチから書き直したようです。

Mackerelのアーキテクチャは,ユーザのサーバにエージェントを入れてもらい,そのエージェントからメトリクスをMackerelのサーバに送るというものでした。そして,ユーザはMackerelのサーバが提供する管理画面からグラフを見たり,アラートを受け取るそうです。Webアプリの部分はScalaで書かれており,エージェントはGo言語で書かれているそうです。このセッションでは,Go言語で書かれたエージェント(mackerel-agent)について解説がされました。

mackerel-agent

mackerel-agentとは,監視対象サーバにインストールされるメトリクス投稿用のGo言語で書かれたプログラムであるそうです。1分ごとにMackerel本体のAPIサーバにメトリクスを投稿するそうで,デフォルトでも各種メトリクスを自動投稿でき,さらにプラグインによる拡張も可能であると説明がされてました。

同氏は,mackerel-agentの開発にGo言語を採用した理由について以下のことを挙げていました。

  • シングルバイナリでコンパイルされるので,セットアップが容易
  • マルチプラットフォーム対応が比較的容易
  • 常駐プロセスを書くことに向いている
  • フットプリントが小さいため,監視対象のサーバーのパフォーマンスに影響を及ぼさない
  • 書いていて楽しい

mackerel-agentソースコード解説

mackerel-agentの実装について,ソースコードを見ながら解説がされました。ここでは,前回のGoConでの@stanaka氏発表で触れられていなかった以下の部分について重点的に話がされました。

  • シグナルハンドリング
  • データ送受信制御
  • グレースフルシャットダウン
ディレクトリ構成

@songmu氏は,ディレクトリ構成は以下のようになっていると述べていました。この構成はパッケージの分け方が細かすぎるため,アンチパターンかもしれないと述べていました。

  • command:メインの処理
  • mackerel:Mackerel APIへの投稿処理
  • agent:メトリクス収集処理を束ねている
  • metrics:各種メトリクス収集処理単位
  • その他
main関数

まず,main.goのmain関数について説明がされました(ソースへのリンクは本稿執筆当時の最新のもの⁠⁠。

func main() {
    conf, printVersion := resolveConfig()
    // (snip)
    logger.Infof("Starting mackerel-agent version:%s, rev:%s", version.VERSION, version.GITCOMMIT)
    // (snip)
    if err := start(conf); err != nil {
        exit(1, conf)
    }
}

main関数では,設定ファイルを読み込み,ロガーを設定して,start関数に処理を渡しています。

start関数

start関数では,以下の処理を行っています(ソースへのリンクは本稿執筆当時の最新のもの⁠⁠。

  • pidファイルの作成
  • ホスト情報・投稿先情報の設定
  • シグナルハンドラの設定
  • command.Run関数に処理を渡す

同氏は,start関数では,以下のようにシグナルハンドルとグレースフルシャットダウンを実装していると述べていました(ソースはセッション発表当時のもの⁠⁠。

c := make(chan os.Signal, 1)
termChan := make(chan chan int) // メインの処理との情報のやりとり
signal.Notify(c, os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGHUP)
go func() { // シグナルハンドリング用のgoroutine
    for sig := range c {
        if sig == syscall.SIGHUP { // sighupを受け取ったらhost情報を読み込み直す
            command.UpdateHostSpecs(conf, api, host)
        } else { // 他のシグナルを受け取ったら処理の終了を待ってexit
            exitChan := make(chan int)
            termChan <- exitChan // チャンネルにチャンネルを渡す
            go func() { // 但し処理に時間が掛かるようなら強制的に終了
                time.Sleep(MAX_TERMINATING_INTERVAL * time.Second)
                exitChan <- 1
            }()
            exitCode := <-exitChan // 渡したチャンネルからexitCodeの返却を待つ
            exit(exitCode, conf)
        }
    }
}()
command.Run(conf, api, host, termChan) // メインの処理

まず,シグナルを受け取るためのチャネルcを作成し,signal.Notifyで受け取るシグナルを設定しています。このとき,syscall.SIGINTではなくos.Interruptを使用しないとWindowsで正しく動かないとのことでした。

そして,シグナルハンドリング用のゴルーチンを起動し,シグナルを受け付けています。ここでsyscall.SIGHUPを受け取った場合は,設定情報を読み込み直し,それ以外のシグナルを受け取ったら実行中の処理の終了を待ってプログラムを終了させます。このとき,メインの処理とのやりとりにtermChanというintチャネルのチャネルchan chan intを使用しています。そして,termChanに渡したチャネルを介して,終了コードを受け取っています。なお,このチャネルのチャネルを使った実装は複雑だと指摘を受けているらしく,本稿執筆当時には使われていませんでした。

Run関数

command.goのRun関数では,メトリクスの収集項目をロードして,Agentオブジェクトを作成して,loop関数に処理を渡しているそうです(ソースへのリンクは本稿執筆当時の最新のもの⁠⁠。

func Run(conf *config.Config, api *mackerel.API, host *mackerel.Host, termChan chan chan int) {
    logger.Infof("Start: apibase = %s, hostName = %s, hostId = %s", conf.Apibase, host.Name, host.Id)
    ag := &agent.Agent{
        MetricsGenerators: metricsGenerators(conf),
        PluginGenerators:  pluginGenerators(conf),
    }
    ag.InitPluginGenerators(api)
    loop(ag, conf, api, host, termChan)
}
loop関数の概要

loop関数ではメインの処理行っているそうで,以下の3つのループからなるそうです。

  • キューからメトリクスを投稿する処理
  • ホスト情報を定期更新する処理
  • メトリクスを取得してキューに入れる処理

このセッションでは,このうちの「キューからメトリクスを投稿する処理」について解説がされました。

なお,loop関数では,以下のようなキューの状態queueStateによって処理を分けているそうです。

type queueState int
const (
    queueStateFirst      queueState = iota // 初期状態
    queueStateDefault                      // 通常
    queueStateQueued                       // キューが溜まっている場合
    queueStateTerminated                   // シグナルを受け取っている場合
)
メトリクスの投稿処理(初期化処理)

はじめに,同氏は以下のような初期化処理を行っていると述べていました。このとき,postQueueのサイズは6時間程度のメトリクスを保持できる程度の大きな値を指定しているそうです。キューの状態はqStateで管理され,ここで初期状態queueStateFirstに設定されます。

// 十分なバッファを溜め込めるように
postQueue := make(chan []*mackerel.CreatingMetricsValue, conf.Connection.Post_Metrics_Buffer_Size)
go func() {
    postDelaySeconds := delayByHost(host)
    qState := queueStateFirst             // キューの状態
    exitChan := make(chan int)            // 処理を抜ける際にexitCodeを送るチャンネル
メトリクスの投稿処理(終了割り込みに対する処理)

終了割り込みに対する処理について,以下のソースコードを基に説明がされました。

for {
    select {
    case exitChan = <-termChan: // シグナルを受け取ったら割り込まれる
        if len(postQueue) <= 0 {
            exitChan <- 0 // キューにデータが残ってなかったら抜ける
        } else { // 残っている場合はキューの状態を更新して処理続行
            qState = queueStateTerminated
        }
    case values := <-postQueue:

シグナルを受け取ると,termChanから終了コードを受け取るためのチャネルが送られてきます。このとき,キューにデータが入っていない場合は,終了コードを送ってプロセスを終了させます。キューにデータが残っている場合は,状態をqueueStateTerminatedにします。

メトリクスの投稿処理(バルク送信)

1分に1回キューにメトリクスが送られてくるため,あまりキューにメトリクスが溜まることはないそうですが,以下のように2件まで同時に送信できるようにしているそうです。

case values := <-postQueue:
    if len(postQueue) > 0 {
        // 送り過ぎないように最大2件
        logger.Debugf("Merging datapoints with next queued ones")
        nextValues := <-postQueue
        values = append(values, nextValues...)
    }
メトリクスの投稿処理(送信タイミングの調整)

キューの状態に応じて,メトリクスを投稿するタイミングを調整する部分について,以下のソースコードを基に説明がされていました。毎分00秒にメトリクスがキューに送られてくるため,すぐにMackerel本体にメトリクスを投稿してしまうと,同時に大量のデータが来てしまうため,Mackerelサーバの負荷が高くなってしまうそうです。そのため,同氏は標準の状態queueStateDefaultの場合には,サーバによって00秒から59秒まで投稿をばらけて遅延させることで負荷分散をしていると述べていました。なお,メトリクスを取得した時間は別途保存しているため,投稿のタイミングを遅らせても問題ないと述べていました。

// 状態に応じてdelayさせるタイミングを調整
delaySeconds := 0
switch qState {
case queueStateTerminated:
    delaySeconds = 1
case queueStateFirst:
    // nop
case queueStateQueued:
    delaySeconds = conf.Connection.Post_Metrics_Dequeue_Delay_Seconds
default:
    // 通常状態で00秒にAPIへのサーバーがアクセスすると困るので投稿時間をhashingしている
    elapsedSeconds := time.Now().Second() % int(config.PostMetricsInterval.Seconds())
    if postDelaySeconds > elapsedSeconds {
        delaySeconds = postDelaySeconds - elapsedSeconds
    }
}
メトリクスの投稿処理(スリープ処理)

メトリクスの投稿を遅らせるのには,以下のようにtime.Sleepを使用しているそうです。このとき,スリープ中にシグナルを受け取っても問題ないように,selecttermChanからも受信しています。

   sleepCh := make(chan struct{})
    go func() {
        time.Sleep(delaySeconds * time.Second)
        sleepCh <- struct{}{}
    }()
sleepLoop:
    for {
        select {
        case <-sleepCh:
            break sleepLoop
        case exitChan = <-termChan:
            qState = queueStateTerminated
            break sleepLoop
        }
    }
メトリクスの投稿処理(投稿処理)

実際にメトリクスを投稿する部分は以下のとおりになっているそうです。この部分には,投稿ができなかったときにスリープして再度投稿する処理が入っているそうです。しかし同氏は,ここでスリープするのはあまり良くないので,改善したいと述べていました。

tries := conf.Connection.Post_Metrics_Retry_Max
for {
    err := api.PostMetricsValues(values)
    if err == nil {
        logger.Debugf("Posting metrics succeeded.")
        break
    }
    tries -= 1
    if tries <= 0 {
        logger.Errorf("Give up retrying to post metrics.")
        break
    }
    time.Sleep(conf.Connection.Post_Metrics_Retry_Delay_Seconds * time.Second)
}
メトリクスの投稿処理(終了処理)

同氏は,ループの最後にシグナルを受け取っていて,キューにデータがない場合は以下のようにプロセスを終了させていると述べていました。

// シグナル受信状態でキューにデータが残ってなかったらexit
if qState == queueStateTerminated && len(postQueue) <= 0 {
    exitChan <- 0
}

同氏はセッションの最後に,mackerel-agentのソースコードは公開されているので,おかしな点があれば指摘ほしいと述べていました。

著者プロフィール

上田拓也(うえだたくや)

KLab(株)所属。Go Conference 2014 Autumnスタッフ。

業務ではJavaScript,Luaなどを扱っている。

大学時代にGo言語に出会い,それ以来Go言語にのめり込む。

時間があると,Go言語の勉強会に参加している。

Go言語のマスコットのGopherの絵を描くのも好き。

Twitter:@tenntenn