Go
GoDay 5

Goサーバのモニタリング

More than 1 year has passed since last update.

5日目担当の@cubicdaiyaです。先月末のGoConではGoのカンファレンスなのにほぼnginxをビルドする話しかしてなかったので今日はちゃんとGoの話をします。


Goで書くサーバプログラム

Goではサーバプログラムを書くためのユーティリティが豊富に揃ってる上に、ゴルーチンやチャネルを利用することで高いパフォーマンスが要求される環境でも十分な性能を発揮することができます。いつだったか「あれはHTTPサーバ書くための言語ですよ」なんて話をとあるエンジニアから聞いたことがあるくらいです。

例えば「Hello, World!」を返すだけのHTTPサーバであれば標準ライブラリのnet/httpを利用することで以下のように書くことが出来ます。


hello_server.go

package main

import (
"fmt"
"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello, World!\n")
}

func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":8080", nil)
}


abで簡単なベンチマークを取ってみましょう。

$ ab -k -n 10000 -c 100 "http://127.0.0.1:8080/" 2>&1 | grep "Requests per second:"

Requests per second: 42209.94 [#/sec] (mean)
$

なかなかの性能です。runtime.GOMAXPROCS(runtime.NumCPU())を利用したりするとさらに上がります。


Goサーバのモニタリング

さて、Goで性能の良いHTTPサーバを書くことができそうだということはわかりました。しかし、プロダクションで利用するにはやはりそれ相応のモニタリングの仕組みを整えたいところです。ぱっと思いつくだけでも以下の項目が浮かびます。


  1. メモリの使用状況

  2. GCの活動状況

  3. 起動しているゴルーチンの数

  4. 内部チャネルの使用状況

  5. サーバへの接続数、requests/sec, etc...

この投稿では1〜4までのGo特有の項目について解説します。ちなみに5をモニタリングするための私のお気に入りの方法は前段にnginxをはさんでそこでモニタリングすることです。(ただし、HTTPサーバ限定です。また、もちろんモニタリングのためだけにはさむわけではありません)


runtimeパッケージを利用する

runtimeパッケージはGoの内部や実行環境の情報を参照できるパッケージです。筆者がよく参照する情報は以下の項目です。

関数または変数
解説
備考

runtime.Version()
Goのバージョン

runtime.Compiler
Goコンパイラの名前

runtime.GOOS
ターゲットOS
linux, darwin等

runtime.GOARCH
ターゲットアーキテクチャ
386, amd64

runtime.NumCPU()
CPUコア数

runtime.NumGoroutine()
起動しているゴルーチンの数

runtime.GOMAXPROCS(0)
Goプログラムに割り当てるCPUコア数

runtime.NumCgoCall()
cgo経由の関数の呼び出し回数

runtime.MemStats
メモリやヒープ、GCの活動状況

この表から分かるようにさきほどの1〜3の項目についてはruntimeパッケージを利用することで結構詳細にモニタリングすることが可能です。さきほどのhello_server.goにruntimeの情報を取得できるハンドラを追加してみます。


hello_server_with_stats.go

package main

import (
"encoding/json"
"fmt"
"net/http"
"runtime"
)

type Stats struct {
GoVersion string `json:"go_version"`
GoOs string `json:"go_os"`
GoArch string `json:"go_arch"`
CPUNum int `json:"cpu_num"`
GoroutineNum int `json:"goroutine_num"`
Gomaxprocs int `json:"gomaxprocs"`
CgoCallNum int64 `json:"cgo_call_num"`
// メモリやGCの項目は多いので省略
}

func statsHandler(w http.ResponseWriter, r *http.Request) {

stats := &Stats{
GoVersion: runtime.Version(),
GoOs: runtime.GOOS,
GoArch: runtime.GOARCH,
CPUNum: runtime.NumCPU(),
GoroutineNum: runtime.NumGoroutine(),
Gomaxprocs: runtime.GOMAXPROCS(0),
CgoCallNum: runtime.NumCgoCall(),
}

statsJson, err := json.Marshal(stats)
if err != nil {
msg := "Response-body could not be created"
http.Error(w, msg, http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, string(statsJson))
}

func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello, World!\n")
}

func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/stats", statsHandler)
http.ListenAndServe(":8080", nil)
}


/statsにアクセスします。

$ curl -s "http://127.0.0.1:8080/stats" | jq '.' 

{
"go_version": "go1.3.3",
"go_os": "darwin",
"go_arch": "amd64",
"cpu_num": 8,
"goroutine_num": 5,
"gomaxprocs": 1,
"cgo_call_num": 0
}
$

runtimeの情報が取れました。簡単ですね。


golang-stats-api-handlerを利用する

さて、上記のように自分でruntimeの情報を取得するハンドラを書いてもいいのですが、毎回サーバを書く度にハンドラのコードをコピペするのは面倒です。実は既にこういった用途のために汎用的なパッケージとしてgolang-stats-api-handlerがあるので筆者は普段これを利用しています。

go get -u github.com/fukata/golang-stats-api-handler

以下がgolang-stats-api-handlerを利用したコードです。


hello_server_with_golang_stats_api_handler.go

package main

import (
"fmt"
"github.com/fukata/golang-stats-api-handler"
"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "Hello, World!\n")
}

func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/stats", stats_api.Handler)
http.ListenAndServe(":8080", nil)
}


自分で書くのと比べてかなりすっきりしたコードになりました。それではあらためて/statsにアクセスしてみましょう。

$ curl -s "http://127.0.0.1:8080/stats" | jq '.' 

{
"time": 1417739346198990300,
"go_version": "go1.3.3",
"go_os": "darwin",
"go_arch": "amd64",
"cpu_num": 8,
"goroutine_num": 5,
"gomaxprocs": 1,
"cgo_call_num": 0,
"memory_alloc": 195296,
"memory_total_alloc": 210104,
"memory_sys": 4196600,
"memory_lookups": 7,
"memory_mallocs": 412,
"memory_frees": 64,
"memory_stack": 40960,
"heap_alloc": 195296,
"heap_sys": 1048576,
"heap_idle": 598016,
"heap_inuse": 450560,
"heap_released": 0,
"heap_objects": 348,
"gc_next": 357200,
"gc_last": 1417739346198630000,
"gc_num": 2,
"gc_per_second": 0,
"gc_pause_per_second": 0,
"gc_pause": [
0.194261,
0.617528
]
}
$

メモリやヒープ、GCの情報も見れるようになりました。情報が取れさえすればあとはMuninやZabbix等のエージェント経由でモニタリングするだけなので簡単ですね。


アプリケーション固有の情報を取得する

runtimeパッケージを利用して取得できるのはあくまでGoの内部や実行環境の情報だけです。アプリケーション固有の情報については自分で何が必要なのかを判断して取得できるようにする必要があります。(e.g. ハンドラ毎の実行回数等)

しかし、4.内部で利用しているチャネルの使用状況は取得したいところです。と言うのもGoのチャネルにはお手軽なインメモリキューとしての側面があるのですが、Goでサーバプログラムを書く際はこれとゴルーチンを組み合わせることで非常に効率のよいプログラムを書くことができます。

一方でチャネルの容量は有限であり、サーバの処理が重くてキューがつまったりするとそこでゴルーチンの実行がブロックされてしまうことがあります。エンキューの処理自体をゴルーチン化してしまえばとりあえずブロックされることは防げますが、そうすると今度はゴルーチンの数がどんどん溜まっていきます。これはGoのプログラムのメモリ肥大化や性能劣化を招きます。

とりあえず、キュー(チャネル)の容量をあらかじめ大きめにしておくのもいいですが、やはりどれくらい使用しているのかがわかるのが望ましいでしょう。

// チャネルの容量を大きめにしておく

Queue := make(chan int, 10240)

チャネルの容量はcap、使用量はlenで取得することができます。

QueueMax := cap(Queue)

QueueUsage := len(Queue)

あとは/stats/appのようなハンドラを定義してこの値を取得するようにすれば特定の「チャネルの使用率が90%を越えました」的なアラートを飛ばすのは難しくないでしょう。


ゴルーチンのリークに注意

チャネルの使用量もそうですが、Goでサーバプログラムを書く時に特に注意したいのがゴルーチンのリークです。例えばゴルーチン化した関数が無限ループしたり、何かしらのロック解除待ちの状態になってしまうとそのゴルーチンはずっと残ります。これがリクエストを処理する度に毎回呼び出されるような箇所で起こってしまうとruntime.NumGoroutine()の数がどんどん増えていってしまい、メモリの肥大化や性能の低下を招いてしまいます。


まとめ

Goで書いたサーバのモニタリングをする際は、


  • Goの内部や実行環境の情報を取得するにはruntimeパッケージあるいはgolang-stats-api-handlerを利用しよう

  • アプリケーション固有の情報は自分で何が必要なのかを判断して取得できるようにしよう

  • チャネルの使用量やゴルーチンのリークに注意しよう

という話でした。