GoでCloudWatchメトリクスを高速に取得

この記事は[千 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/sen)の10日目の記事です。

## はじめに

監視システムの構成は各社いろいろなパターンがあると思います。
弊社の場合は、複数AWSアカウントを一元管理でき、監視をカスタマイズしやすい、など
の理由から、Zabbixを利用したマスターサーバ／複数Proxy構成を採用しています。

ただ、AWS上で動くシステムの場合は、CloudWatchメトリクスも監視したいので、
CloudWatchから定期的にメトリクスを収集して、Zabbixに投入する方式にしています。

この記事では、上記の目的でgolang実装したCloudWatchメトリクス収集ツールについて
紹介したいと思います。

## 道のり

詳細は省きますが、Golangでの初回実装時は収集速度が満足いくものではありません
でした。現在のように高速化するまでに、辿った経緯も下記に紹介しておきます。

- GetMetricStatistics APIを使いメトリクスを順次取得
    - GetMetricStatisticsは１メトリクスずつ取得するAPI
    - 取得対象メトリクスが多いと収集に時間がかかる
- GetMetricData APIを使い複数メトリクス一括取得
    - GetMetricDataは、1リクエストで複数メトリクス取得できるAPI(最大100)
    - だいぶ速くなったが、golang使うなら並列化したいよねー、となり
- goroutineで並列化
    - 速い！！　でも、レート制限に達してスロットリングされる事象が頻発した

```:スロットリング時のエラーメッセージ
panic: Throttling: Rate exceeded
   status code: 400, request id: 10368e95-e311-11e8-aabd-2d59e6c47896
```
- time.Tickを使ってレート制限回避
    - 低負荷で安定して高速に動くようになった！

## 実装

コード自体は、[こちら](https://github.com/miyaz/cloudwatch-metrics-collector)にあります。
README.mdをみてもらえばすぐに実行できるはず。

今回の実装の肝となる、並列化＆レート制限の部分のみ説明します。

```golang:main.go
// CloudWatchの制限参照
// see) https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html
// API制限（秒間最大実行数） 
const MaxRateLimitListMetrics = 25

// 指定したMetricNameのメトリクス一覧を並列に取得する
func parallelListMetrics(service *Service, metricNames []string) (metricList []*cloudwatch.Metric) {
    var wg sync.WaitGroup
    var mu sync.Mutex

    // rate-limit. see https://gobyexample.com/rate-limiting
    requests := make(chan int, len(metricNames))
    for i := 0; i < len(metricNames); i++ {
        requests <- i
    }
    close(requests)
    limiter := time.Tick(1000 / MaxRateLimitListMetrics * time.Millisecond)
    for req := range requests {
        metricName := metricNames[req]
        wg.Add(1)
        go func(service *Service, metricName string) {
            defer wg.Done()
            <-limiter
            resp := listMetrics(service, metricName)
            mu.Lock()
            defer mu.Unlock()
            for _, metric := range resp {
                metricList = append(metricList, metric)
            }
        }(service, metricName)
    }
    wg.Wait()

    return
}
```

## 説明

#### レート制限処理

```golang:
// API制限（秒間最大実行数） 
const MaxRateLimitListMetrics = 25
```

APIの[制限](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/cloudwatch_limits.html)である1秒あたりに実行可能なトランザクション数を指定しています。
ListMetricsの制限値が25なので、40ミリ秒(1000ミリ秒/25)間隔でAPIを実行することで
レート制限にかからない、ということになります。

下記のように、40ミリ秒間隔で実行するためにtime.Tickを使いました。[参考1](http://budougumi0617.github.io/2018/11/18/loop-by-time-tick-in-golang/#n%E7%A7%92%E3%81%8A%E3%81%8D%E3%81%AB%E3%81%AA%E3%81%AB%E3%81%8B%E3%81%99%E3%82%8B%E7%84%A1%E9%99%90%E3%83%AB%E3%83%BC%E3%83%97) [参考2](https://gobyexample.com/rate-limiting)

```golang:
limiter := time.Tick(1000 / MaxRateLimitListMetrics * time.Millisecond)
```

各goroutineの中の`<-limiter`でlimiterチャンネルを40ミリ秒間隔で受け取り
処理を行います。

#### goroutineの待ち合わせ

全てのgoroutine処理を待ち合わせするために、sync.WaitGroupを使っています。
これは、wg.Addでインクリメント、wg.Doneでデクリメントし、関数最後のwg.Waitで
全ての処理が終わる（ゼロになる）まで、待ち合わせをします。


```golang:
    var wg sync.WaitGroup
    〜
    for req := range requests {
        wg.Add(1)
        go func(service *Service, metricName string) {
            defer wg.Done()
    〜
        }(service, metricName)
    }
    wg.Wait()
    return
}
```

#### 変数書き込み時の排他ロック

戻り値変数(metricList)に、それぞれのgoroutineが同時に書き込むのを防ぐために
sync.Mutexを使っています。
Lockを取得して、API応答をmetricList変数に加えたあと、goroutineを抜ける際に
deferによりUnlockされます。
これによりmetricListアクセスが排他的になり安全にデータが格納されます。

```golang:
            resp := listMetrics(service, metricName)
            mu.Lock()
            defer mu.Unlock()
            for _, metric := range resp {
                metricList = append(metricList, metric)
            }
```

## 性能

弊社のある環境(t2.smallインスタンス)で実行すると、1400メトリクスを8秒ほどで取得でき
CPU使用は10%程度でした。
＃最初の実装に比べて、10倍ほど速くなってます＾＾

## まとめ

CloudWatchメトリクスをレート制限にかからない範囲で並列に取得する方法について
ざっくり説明しました。

CloudWatchメトリクスをZabbixに投入している環境はそう多くない気がしますが、
取得対象を柔軟にyamlで指定できるなど汎用的な作りにしているので、もし同じこと
されている環境であればすぐに使えると思います。試してみて下さい。
（ちなみに、Golang実装経験少ないのでもっとよい書き方あればツッコミ大歓迎です）

明日は @jumperson さんのiOSネタです。