MackerelでGoroutineのブロックを検知したい

[Mackerel Advent Calendar 2020](https://qiita.com/advent-calendar/2020/mackerel) 22日目のエントリです。

先日、とあるGo製のアプリケーションで、Goroutineの処理がブロックされて停止していることで、アプリケーション全体の動作が停止するということがありました。
このような問題を検知するための監視をMackerelで行う方法を考えてみたので、このエントリで紹介します。

Mackerelでは、mackerel-agentのプラグイン機構やカスタムメトリックなどの機能によって、監視対象のシステム/アプリケーションの特性にあわせた様々な監視を実装することができます。これらの機能を使って監視実装をしてみます。

## アプリケーションの動作が停止したときの状態

まずはじめに、問題のアプリケーションの動作が停止していたときに、どのような事象が発生していたかを説明します。

以下は、アプリケーションに問題が発生していたタイミングにおけるGoroutineのスタックトレースの一部です。

```
goroutine 1 [chan receive, 1419 minutes]:
main.run(*app).Wait(0xc45114f3f0, 0x0, 0x0)
        /opt/app/app.go:22 +0x144
...
goroutine 12 [chan receive, 1419 minutes]:
main.handle.func1(0xc4201b56a0, 0xc44601d3f0)
        /opt/app/app.go:50 +0x41
...
goroutine 176 [IO wait, 1419 minutes]:
internal/poll.runtime_pollWait(0x7f1896c1ad60, 0x72, 0xc4206c88c0)
        /usr/local/go/src/runtime/netpoll.go:173 +0x57
internal/poll.(*pollDesc).wait(0xc4206b2318, 0x72, 0xffffffffffffff00, 0x94f100, 0xd76bd0)
        /usr/local/go/src/internal/poll/fd_poll_runtime.go:85 +0x9b
internal/poll.(*pollDesc).waitRead(0xc4206b2318, 0xc4202fa000, 0x1000, 0x1000)
        /usr/local/go/src/internal/poll/fd_poll_runtime.go:90 +0x3d
internal/poll.(*FD).Read(0xc4206b2300, 0xc4202fa000, 0x1000, 0x1000, 0x0, 0x0, 0x0)
        /usr/local/go/src/internal/poll/fd_unix.go:157 +0x17d
net.(*netFD).Read(0xc4206b2300, 0xc4202fa000, 0x1000, 0x1000, 0xc4200c0120, 0x28b621587cb3ad0b, 0xc4206c89d0)
        /usr/local/go/src/net/fd_unix.go:202 +0x4f
net.(*conn).Read(0xc420670230, 0xc4202fa000, 0x1000, 0x1000, 0x0, 0x0, 0x0)
        /usr/local/go/src/net/net.go:176 +0x6a
bufio.(*Reader).Read(0xc420611500, 0xc42132ade8, 0x8, 0x8, 0xc420028070, 0x44ca18, 0x38caa669fac61c)
...
```

各Goroutineのスタックトレースは、`goroutine <数字> […]`という行で始まっています。
ここでは、`goroutine 1`, `goroutine 12`, `goroutine 176`をピックアップして載せました。各Goroutineは、ざっくり説明すると次のような状態です。


- `goroutine 1 [chan receive, 1419 minutes]`, `goroutine 12 [IO wait, 1419 minutes]`
    - `goroutine 176`の処理完了を待って処理がブロックされているGoroutine
    - アプリケーション内のchannelを受信する箇所でブロックされている
- `goroutine 176 [IO wait, 1419 minutes]`
    - 外部サービスとのコネクションを確立したまま処理が停止したGoroutine

## Mackerelで監視する

さて、アプリケーションに同様の問題が発生したことをMackerelで検知するために、監視の実装方法を考えていきます。

スタックトレースには次のような行がありました。

> `goroutine 176 [IO wait, 1419 minutes]`

`goroutine 176`は`IO wait`の状態が1419分続いているということを表します。
これは、アラートとして検知できるようにしたい「アプリケーションが停止している状態」を表している出力なので、このようなスタックトレースの出力があることを何らかの方法でチェックできるようにするとよさそうです。

監視対象アプリケーションからスタックトレースを定期的に取得して（オーバーヘッドは無視できるものとします）、スタックトレースに含まれる`[IO wait, 1419 minutes]`の部分をカスタムメトリックとして投稿して、そのカスタムメトリックに対して監視ルールを設定することで、アラートとして検知できるようにしたいと思います。

以下が実装の流れです。​​

- 監視対象のアプリケーションで`net/http/pprof`をimportする
- カスタムメトリックを取得するプログラムを作る
- Mackerelで監視ルールを設定する

### 監視対象のアプリケーションで`net/http/pprof`をimportする

まず、監視対象アプリケーションのGoroutineのスタックトレースをアプリケーション外から取得できるようにする必要があります。
そのために、Goの標準パッケージに含まれる`net/http/pprof`を利用します。`net/http/pprof`をアプリケーションでimportすると、いくつかのデバッグ用のハンドラが追加されます。そのひとつにGoroutineのスタックトレースを取得するためのハンドラがあるので利用します。

監視対象のアプリケーションで、以下のように`net/http/pprof`をimportするだけでOKです。

```go
import _ "net/http/pprof"
```

### カスタムメトリックを取得するプログラムを作る

`net/http/pprof`をimportしたアプリケーションの、`/debug/pprof/goroutine?debug=2`にリクエストをすると、そのアプリケーションの全てのGoroutineのスタックトレースをレスポンスとして受け取ることができます。

カスタムメトリックを取得するプログラムでは、このエンドポイントを利用して監視対象アプリケーションのGoroutineのスタックトレースを取得し、そこから以下の部分をパースしてカスタムメトリックとして収集できるようにします。

> `goroutine 176 [IO wait, 1419 minutes]`

ここで収集するカスタムメトリックは、Goroutineの状態とその状態の継続時間を組み合わせたものをキーにして、値はキーに該当するGoroutineの数とします。
具体的には、`IO wait`であるGoroutineは、以下のいずれかのキーに該当することになります。

- `IO_wait`
- `IO_wait_1_minutes_over`
- `IO_wait_10_minutes_over`
- `IO_wait_100_minutes_over`
- `IO_wait_1000_minutes_over`

これに当てはめると、スタックトレースに`goroutine 176 [IO wait, 1419 minutes]`という行が含まれていた場合、`IO_wait_1000_minutes_over`の値が1増える、という感じです。

サンプルとして作成したプログラムのソースコードです。

```go

package main

import (
        "fmt"
        "io/ioutil"
        "net/http"
        "regexp"
        "strconv"
        "strings"
        "time"
)

var reg = regexp.MustCompile(`^goroutine [0-9]+ \[([a-zA-Z ]+)(, )?(([0-9]+) minutes)?.*\]`)

var prefix = "goroutine"

func main() {
        resp, err := http.Get("http://localhost:6060/debug/pprof/goroutine?debug=2")
        if err != nil {
                // エラー処理
        }
        defer resp.Body.Close()

        body, err := ioutil.ReadAll(resp.Body)
        if err != nil {
                // エラー処理
        }

        metric := make(map[string]int)
        for _, b := range strings.Split(string(body), "\n") {
                if strings.HasPrefix(b, "goroutine") {
                        m := reg.FindStringSubmatch(b)
                        state, duration := m[1], m[4]
                        state = strings.Replace(state, " ", "_", -1)
                        if duration == "" {
                                if _, ok := metric[state]; !ok {
                                        metric[state] = 1
                                } else {
                                        metric[state]++
                                }
                                continue
                        }

                        i, err := strconv.Atoi(duration)
                        if err != nil {
                                // エラー処理
                        }

                        if i >= 1000 {
                                s := fmt.Sprintf("%s_1000_minutes_over", state)
                                if _, ok := metric[s]; !ok {
                                        metric[s] = 1
                                } else {
                                        metric[s]++
                                }
                                continue
                        }

                        if i >= 100 {
                                s := fmt.Sprintf("%s_100_minutes_over", state)
                                if _, ok := metric[s]; !ok {
                                        metric[s] = 1
                                } else {
                                        metric[s]++
                                }
                                continue
                        }

                        if i >= 10 {
                                s := fmt.Sprintf("%s_10_minutes_over", state)
                                if _, ok := metric[s]; !ok {
                                        metric[s] = 1
                                } else {
                                        metric[s]++
                                }
                                continue
                        }

                        s := fmt.Sprintf("%s_1_minutes_over", state)
                        if _, ok := metric[s]; !ok {
                                metric[s] = 1
                        } else {
                                metric[s]++
                        }
                }
        }

        ts := time.Now().Unix()
        for k, v := range metric {
                fmt.Printf("%s.%s\t%d\t%d\n", prefix, k, v, ts)
        }
}
```

プログラムを実行すると、次のような出力が得られます。

```

$ /path/to/mackerel-plugin-goroutine
goroutine.syscall_1000_minutes_over     2       1608554105
goroutine.chan_receive_1_minutes_over   1       1608554105
goroutine.syscall       5       1608554105
goroutine.select_1000_minutes_over      4       1608554105
goroutine.select        172     1608554105
goroutine.syscall_10_minutes_over       1       1608554105
goroutine.chan_receive  1       1608554105
goroutine.IO_wait_10_minutes_over       1       1608554105
goroutine.IO_wait_1000_minutes_over     1       1608554105
goroutine.semacquire_1000_minutes_over  6       1608554105
goroutine.running       1       1608554105
goroutine.select_10_minutes_over        2       1608554105
goroutine.chan_receive_1000_minutes_over        11      1608554105
goroutine.select_1_minutes_over 1       1608554105
```

出力フォーマットは以下に沿っています。
https://mackerel.io/ja/docs/entry/advanced/custom-metrics

> メトリックの投稿
> 設定ファイルで指定するコマンドは、標準出力の各行に以下のフォーマットの出力をすることが期待されます（`\t` はタブ文字です）:
> 
> {metric name}\t{metric value}\t{epoch seconds}

このプログラムの出力をカスタムメトリックとして投稿するために、mackerel-agent.confに設定を追加します。

```
[plugin.metrics.goroutine]
command = "/path/to/mackerel-plugin-goroutine"
```

ここまでやると、Mackereでカスタムメトリックのグラフが見られるようになります。

![スクリーンショット 2020-12-21 21.44.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46012/637a6f6a-6cc8-3c03-06eb-52b148408941.png)

### Mackerelで監視ルールを設定する

あとは、ドキュメントを参考に監視ルール設定すればOKです。
​​https://mackerel.io/ja/docs/entry/howto/alerts

次のように設定をすれば、IO waitの状態が1000分以上継続しているGoroutineが存在している状態をアラートとして検知することができます。

![スクリーンショット 2020-12-21 22.11.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46012/3a410150-2a7a-4835-c7fc-c1878acb3313.png)

これで、以下で述べていたようなチェックをしてアラートとして検知することができます。

> `goroutine 176`は`IO wait`の状態が1419分続いているということを表します。
これは、アラートとして検知できるようにしたい「アプリケーションが停止している状態」を表している出力なので、このようなスタックトレースの出力があることを何らかの方法でチェックできるようにするとよさそうです。

## まとめ

監視を実現するためには、メトリックの取得、収集、チェック、アラーティング、と色々なコンポーネントが必要になるのですが、Mackerelでは「メトリックの取得」の部分だけ自前で用意すれば、あとのことはMackerelに任せて、オリジナルな監視を追加することができます。便利。