GoでZabbixを爆速にしたかった


はじめに

こんにちは。
CYBIRDエンジニア Advent Calender 2017の22日目は@gucchonさんのゲーム開発をWebViewからUnityに乗り換えて、苦労したこと、良かったことでした。

本日の記事のあらすじ・動機

  • タイトルからもお察しかと思いますが, 失敗談になります。
  • 弊社ではサーバの監視にZabbixを使用しております。(一部はmackerelを使用)。
  • Zabbixを容易に拡張する手段として"UserParameter"と"外部スクリプト"が存在します。
    • しかし, これらはメトリクスの取得の度にプロセスをforkするので負荷がかかります。
    • 外部スクリプトの過度の使用によってパフォーマンスが劣化することはZabbixのドキュメントに明記されております。
    • 弊社(のAWSを使用した案件)では外部スクリプトに頼った監視になっており, 監視サーバのインスタンスタイプが本番環境のWebサーバのインスタンスタイプを超えているケースがあったりします。
  • Zabbixサーバの負荷をなんとか抑えたいということが今回の動機になります。
  • Zabbixモジュールを使用することで, ネイティブ実装と同じパフォーマンスを得ることができる。
    • PHPのCGIモードとmoduleモードでパフォーマンスが異なる理由と同じになります。
    • UserParameter・外部スクリプトを使用するときのようなオーバーヘッド(fork)が削減されます。
    • ネイティブ実装と同じパフォーマンスを得ることができる。
    • Zabbixサーバのインスタンスタイプを落とすことが出来るかもしれない。
    • しかしZabbixモジュールを実装するためには基本的にはCで実装する必要があります。
  • そこでGolangで気軽にZabbixモジュールを作成して使用してみました。
    • しかし後述する, "ZabbixとGoランタイムの相容れないアーキテクチャ"によって失敗するという話になります...
  • 着想・実装にあたっては, @ike_daiさんの記事Golangを使ってZabbixを拡張大いに参考にさせていただいております。

実際にやってみた結果...

実装

  • 変数等のネーミングセンスがない件については, ご容赦ください。
    • AWSのCredentialを.envファイルから読み込んでおります。
    • github.com/cavaliercoder/g2zを使用させていただいております。
    • 既に作者様が行ったパフォーマンスのテストの結果がperformance.mdに記載されております。
mod_zabbix_go.go
package main

import (
    "C"
    "errors"
    "log"
    "strings"

    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/credentials"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/ec2"
    "github.com/joho/godotenv"
    g2z "gopkg.in/cavaliercoder/g2z.v3"
)

// main ... 共有ライブラリなので通常は実行されない
func main() {
    panic("THIS_SHOULD_NEVER_HAPPEN")
}

// env_load ... pathに存在する.envを読み込み環境変数にマッピング
func env_load(path string) {
    err := godotenv.Load(path)
    if err != nil {
        log.Fatalf("Error loading .env file in %v.\n", path)
    }
}

// init ... 共有ライブラリ読み込み時に実行される
func init() {
    env_load("/etc/zabbix/aws.env") // AWS_ACCESS_KEY_IDとAWS_SECRET_ACCESS_KEYの値を参照
    g2z.RegisterStringItem("go.echo", "Hello World", Echo)
    g2z.RegisterStringItem("go.ec2_name2id", "error", Ec2Name2Id)
}

// Echo ... 引数の値を全て連結して返す
func Echo(req *g2z.AgentRequest) (string, error) {
    return strings.Join(req.Params, " "), nil
}

// Ec2Name2Id ... EC2インスタンスのタグ名からインスタンスIDを取得
func Ec2Name2Id(req *g2z.AgentRequest) (string, error) {
    if len(req.Params) != 1 {
        return "", errors.New("can use only 1 argument(s).")
    }

    sess, err := session.NewSession(&aws.Config{
        Credentials: credentials.NewEnvCredentials(),
    })
    if err != nil {
        return "error", err
    }

    svc := ec2.New(
        sess,
        aws.NewConfig().
            WithRegion("ap-northeast-1").
                WithLogLevel(
                    aws.LogDebugWithRequestRetries |
                    aws.LogDebugWithRequestErrors  |
                    aws.LogDebugWithHTTPBody,
                ),
    )

    params := &ec2.DescribeInstancesInput{
        Filters: []*ec2.Filter{
            &ec2.Filter{
                Name: aws.String("tag:Name"),
                Values: []*string{
                    aws.String(req.Params[0]),
                },
            },
        },
    }

    res, err2 := svc.DescribeInstances(params)
    if err2 != nil {
        return "error", err2
    }

    for _, r := range res.Reservations {
        for _, i := range r.Instances {
            return *i.InstanceId, nil
        }
    }
    return "empty", nil
}

ビルド環境

  • AWSのCodeBuildを使用してビルドしております。CircleCI?知らない子ですね...
    • Dockerイメージはgolang:1.9.2を使用してビルドしております。
buildspec.yml
version: 0.2

phases:
  install:
    commands:
      - wget https://github.com/Masterminds/glide/releases/download/v0.13.1/glide-v0.13.1-linux-amd64.tar.gz
      - tar xvf glide-v0.13.1-linux-amd64.tar.gz
      - install -o root -g root -m 0755 linux-amd64/glide /usr/local/bin/glide
  pre_build:
    commands:
      - /usr/local/bin/glide install
  build:
    commands:
      - GOOS=linux GOARCH=amd64 go build -buildmode=c-shared -o mod_zabbix_go.so main.go

artifacts:
  files:
    - 'mod_zabbix_go.so'
  discard-paths: no

環境設定

  • Moduleを読み込むよう設定を記述し, Zabbix Agentを起動・再起動します。
/etc/zabbix/zabbix_agentd.conf
+ LoadModulePath=/var/lib/zabbixsrv/modules
+ LoadModule=mod_zabbix_go.so
systemctl restart zabbix-agent.service

動作確認

  • 実際に確認してみますと, リクエストを飛ばすところでハングっているようです...
$ zabbix_get -s localhost -k 'go.echo[hello, ntrv]'
hello ntrv # OK!!
$ zabbix_get -s localhost -k 'go.ec2_name2id[ntrv-test]'

zabbix_get [24960]: Timeout while executing operation # i-xxxxxxと返ってくることを期待していた...

失敗した原因について

  • マルチスレッドのgoランタイムでプロセスを安全にフォークすることは不可能
    • 一般的にシングルスレッドのプログラムがマルチスレッドとなる共有ライブラリをdlopen()で呼び出した段階でforkすることが出来ない。
    • https://github.com/golang/go/issues/15538
  • 共有ライブラリをロードしたプロセスがfork()を呼び出した後, 子プロセスでGo codeを使用できない。
    • エージェントがforkする際, Copy-on-WriteでメモリマッピングをコピーするがGoランタイムスレッドをコピーしてくれない。
      • 結果, g2zで記述した関数が呼び出されると, 子プロセスに存在しないスレッドを使用しようとしてデッドロックに陥る?
    • 詳しい方, 教えて頂けるとありがたいです...

References

Zabbix負荷問題を軽減する上で考えうる別の解決方法

  • 失敗してしまったので
  1. Zabbixをやめる
  2. EC2のCloudWatchメトリクスを取得する場合には, 外部スクリプトではなくUserParameterを使用する。
    • Zabbixサーバに負荷が集中するのを防ぐため。
    • RDS等ZabbixAgentが使用出来ない場合に外部スクリプトを使用する。
  3. Zabbix Senderを使用する。
    • 別途エージェントを用意してそこでメトリクスを収集し, ZabbixサーバのZabbix Trapperに投下する。
    • 弊社ですとHTTPステータスコードやアクセス数を, Webサーバ上のtd-agentからZabbix Trapperに送っております。

最後に

今回はZabbixを爆速化しようとして失敗してしまったことをまとめました。
GoでZabbix Sender専用のエージェントを用意して, エージェント上でAWS APIの結果を収集しZabbixサーバに送ってもよさそうですね。
時間があるときに試してみようかと思います。

また今回の経験を通して, Goのことというよりはマルチスレッドプログラミングが分かっていなかったということを痛感いたしました。Golangの文法が簡単だからと言ってなめておりました。
そのあたりの知識を身に着けなければなと考えております。

CYBIRDエンジニア Advent Calendar 2017の24日目は@koki_yamadaさんの"Unityのコード編集にVimを使ってみた"です!
私の冬休みの宿題はneobundle.vim/neocomplete.vimdein.vim/deoplete.nvimに置き換えることなので参考にしようかと思います。
どうぞお楽しみに!