Go Conference 2014 Autumnレポート

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

Go Conference 2014 Autumnでは、8つのセッションと7つのライトニングトーク(LT)がありました。この記事ではその中から3つのセッションについてまとめたいと思います。

App Engine for Golang Performance(@sinmetal氏)

このセッションでは、@sinmetal氏からGoogle App Engine ⁠GAE)for GolangManaged VMsの紹介とそれらのパフォーマンスについての話がされました。なお、このセッションの資料は以下のリンクから閲覧できます。

写真1 @sinmetal氏の発表の様子
写真1 @sinmetal氏の発表の様子

Goolge App Engine for Golang (GAE/G)

まずはじめに、GAEの説明がされました。同氏は、GAEはGoolgeが提供するインフラの上でWebアプリを動かすためのPasSであると述べていました。GAEには以下のような特徴があるそうです。

  • すべてGoogleが提供するインフラの上で動くため、インフラのことを心配する必要がない
  • オートスケーリングというリクエストに合わせて、自動的にインスタンスが増える機能がある
  • Webアプリを作るためのプラットフォームの提供

また同氏は、GAEでGo言語を使うメリットとして以下のことを挙げていました。

  • Go言語が提供する標準パッケージnet/httpを使って書ける
  • gojiなどのほとんどのWebフレームワークが動作する
  • パフォーマンスが良い

一方、デメリットとして以下のことを挙げていました。

  • Go言語のバージョンアップへの追従が遅い(セッション発表当時は Go 1.2)
  • GOMAXPROCS1であるため、ゴルーチンが複数のコアで動かない
  • 独自にツールをインストールしたり、カスタマイズすることができない
  • ベータ版であるためプロダクトで使いづらい

Managed VMs

同氏は、Managed VMsとは、Googleの提供するIasSである、Google Compute Engine(GCE)上でGAEのコンテナを動かす機能だと述べていました。そして、Managed VMsを使うメリットとして以下のことを挙げていました。

  • ローカルファイルにアクセスできる
  • Dockerfileを書き換えることでカスタマイズできる
  • コア数を指定することができるので、GOMAXPROCSが変更できる

しかし、一方で現在は以下のようなデメリットがあると述べていました。

  • デプロイが非常に遅い(5分くらいかかる)
  • auto-scalingの最小値が1であるため、常時インスタンスが起動し続ける

Managed VMsについては、同氏の以下の記事も参考になるかと思います。

パフォーマンスの計測

今回のパフォーマンス計測では、GAEおよびManaged VMs上に、Hello, Woldを返すような簡単なWebアプリをデプロイし、レスポンスを返すまでの時間を計測したそうです。しかし、GCE上から計測対象のWebアプリにリクエストを送るアプリを作り、かかった時間をBig QueryのストリーミングAPIを使って集計し、計測したそうです。また、比較対象としてJavaで書かれたWebアプリを用いたそうです。GAE上でGo言語で書かれたWebアプリとJavaで書かれたものを動かして比較すると、以下のような結果を得ることができたそうです。

  • Javaで書かれたものは、スピンアップに時間がかかる
  • Go言語で書かれたものは、Javaで書かれたものと比べて、半分のインスタンス数で処理することが可能

そして、Go言語とJavaで書かれたWebアプリをそれぞれManaged VMs上で動かすと以下のような結果が得られたそうです。

  • Go言語で書かれたものは、インスタンス1つで十分処理できた
  • Javaで書かれたものは、503エラーを返していたため、うまく計測できなかった

同氏は、Managed VMsはまだベータ版で不安定だが、今後改善されることを期待していると述べていました。また、GAE/Gがベータ版でなくなることを切に願っているようでした。

Golang @ISUCON(@y_matsuwitter氏)

このセッションでは、@y_matsuwitter氏からISUCON4にGo言語を使って出場した話がされました。なお、このセッションの資料は以下のリンクから閲覧できます。

写真2 @y_matsuwitter氏の発表の様子
写真2 @y_matsuwitter氏の発表の様子

ISUCONと出題される問題

ISUCONとは、⁠Iikanjini Speed Up CONtest」の略だそうで、8時間で与えられたWebアプリを出来る限り高速にするコンテストのことだそうです。ISUCONの予選の問題は、AWSのインスタンス1つでMySQLをDBとして使ったWebアプリが多く、ISUCON4では銀行のログインページが題材だったそうです。また、本戦の問題はサーバ3~4台で画像処理や動画の配信など、ある程度負荷の高い処理を含むWebアプリが多く、ISUCON4では動画広告の配信サーバが題材だったそうです。

高速なWebサーバのために

同氏は、高速なWebサーバを実現するためには、以下のようなリソースを出来るだけ使い切る必要があると述べていました。そして、そのうえで適切な処理にリソースを割り当てることが大切だと主張していました。

  • CPU(エンコードやデコード、画像処理)
  • メモリ(プロセス上のデータ量)
  • データ通信(帯域に制限がある中でのサーバ間、サーバ・クライアント間の総通信量)

そのため、言語の性能自体は問題にならないと同氏は主張していました。一方で、Go言語を使用するメリットとして、以下のような改善効率の高さを挙げていました。

  • 構文がシンプルなので、短時間のチーム開発向き
  • 並列・並行処理でのメモリの扱いが楽
  • デプロイが楽
  • 標準パッケージが実用的

取り組んだこと

Webアプリの高速化のために、プロセスキャッシュとタスクの分散処理を行ったそうで、それぞれ説明がされていました。

はじめに、プロセスキャッシュについて説明がされました。同氏は、プロセスキャッシュとは、データをプロセス上にすべて保持しておくことだと述べていました。そして、プロセスキャッシュを行うことで、I/O待ちを減らうことができると主張していました。また、ISUCONではデータの永続化が必須であるため、プロセスキャッシュから適当なタイミングでRedisやファイルに書き込む必要があると述べていました。

同氏は、プロセスキャッシュを実現するためには、複数のゴルーチンからアクセスされても整合性が取れることを保証する必要があると述べていました。複数のゴルーチンから1つの変数にアクセスすることで起きるレースコンディションを避けるには、チャネルを使ってデータをやりとりするか、きちんとロックを取る必要があると主張していました。そして、ロックをとるには以下のような標準パッケージであるsyncパッケージの機能を使うのが良いと述べていました。

  • sync.RWMutexReadWriteでロックレベルを分離したロック
  • sync/atomic:カウンタなどの処理を作るのに重宝するパッケージ
  • atomic.Value:Go言語の1.4から導入されたアトミックに任意のデータ型の値を扱うことができる

つぎに、タスクの分散処理について話されました。同氏は、タスクの分散処理を行う要件として以下の2つを挙げていました。

  • 処理に時間がかかるものを非同期かつ平行実行数を制限して処理したい
  • 画像・動画などは重複して処理したくない

同氏は、タスクの分散処理を行うためには、タスクキューを作って、そこからタスクを取得し分散して処理すると良いと述べていました。タスクキューを実現するには、キャパシティ付きのチャネルとゴルーチンを使うことで実現できるそうです。このタスクキューは、チャネルに重たいタスクを入れておき、ゴルーチンをワーカーとして動かしておいて、ゴルーチンからフェッチすることで、チャネルからタスクを取得して処理を行うというものでした。また、ゴルーチンの数を制限することで並行実行数を制限して非同期に処理できるとのことでした。

ISUCONでは、画像など処理に時間がかかるものを非同期で処理している際に、その画像を取得するリクエストが来た場合、そのリクエストを失敗させてしまうと減点されてしまうそうです。しかし、画像の取得時に処理するのは時間がもったいないと同氏は主張していました。そのため、サーバが1台の場合は、sync.Condをゴルーチン間で共有し、以下のように処理中のものを選択的に待つと良いと同氏は述べていました。

c := sync.NewCond(&sync.Mutex{})

go func() {
    fmt.Println("doing heavy task.")
    time.Sleep(5 * time.Second)
    c.Broadcast()
}()
c.L.Lock()
c.Wait()
fmt.Println("completed!!")

一方、複数台のサーバの場合はsync.Condを共有することができないため、同氏はy-matsuwitter/mcondというライブラリを作成したそうです。このライブラリは、Redisnet/httpパッケージを使って、複数台のサーバ間でsync.Condを扱えるようにしたものだそうです。ライブラリのインターフェースは、sync.Condと似た形にしており、重い処理をする側では、以下のように使えるそうです。

重い処理をする側
mc := mcond.NewMCond(mcond.MCondOption{})
key := "test"
mc.AddCond(key)
mc.AddHost("localhost:9012")
mc.Clear()

mc.AddProcessing(key)
go func() {
    fmt.Println("doing heavy task.")
    time.Sleep(5 * time.Second)
    mc.AddCompleted(key)
    mc.Broadcast(key)
}()

mc.WaitForAvailable(key)
fmt.Println("completed!!")
time.Sleep(time.Second)

また、処理した結果を使用する側では以下のように使えるそうです。

処理した結果を使用する側
mc := mcond.NewMCond(mcond.MCondOption{})
key := "test"
mc.AddCond(key)
mc.Start()
go func() {
    mc.WaitForAvailable(key)
    fmt.Println("this key available!! :" + key)
}()
time.Sleep(time.Minute)

同氏は、タスクをできるだけ一定のサーバに振り分けるために、コンシステントハッシュライブラリのstathat/consistentを利用すると良いと述べていました。ISUCON4では、帯域を使い潰すために、以下のように複数のWebDAVサーバに振り分けるのに使用したそうです。

const (
    webdavHost  = "http://10.0.0.1"
    webdavHost2 = "http://10.0.0.1"
)

var webdavRouter *consistent.Consistent
func init() {
    webdavRouter = consistent.New()
    webdavRouter.Add(webdavHost)
    webdavRouter.Add(webdavHost2)
}

func getWebdavHost(key string) string {
    server, _ := webdavRouter.Get(key)
    return server
}

同氏はセッションの最後に、もっとGo言語をプロダクションで使って欲しいと話していました。プロダクションで使う企業が増えてくれば、知見も増えてくるので嬉しいとのことでした。

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のソースコードは公開されているので、おかしな点があれば指摘ほしいと述べていました。

おすすめ記事

記事・ニュース一覧