fluentbitを使ってk8s上のGoアプリのカスタムメトリクスを回収する

fluentbitを使ってk8s上のGoアプリのカスタムメトリクスを回収する

March 2, 2020
Kubernetes, Programming
kubernetes, elasticsearch, fluentbit, golang

この記事でやること

この記事では、fluentbit を用いて k8s 上のアプリケーションのログを回収して ElasticSearch に送る方法をまとめます。またログの中にカスタムメトリクスを出力して、それを Kibana で可視化も行います。

具体的には Go アプリに以下のようなログを吐き出させるようにして

{
  "count": 0,
  "file": "main.go:16",
  "func": "main.main",
  "level": "info",
  "msg": "sample log",
  "text_sample": "fooooo",
  "time": "2020-03-02T18:22:24+09:00"
}

Kibana でちゃんとメトリクスとして扱えるように送信します。

image.png

fluentbit とは

https://www.treasuredata.co.jp/opensource/

fluentd などを開発している Treasure Data が開発しているログ収集ツールです。 fluentd が Ruby で作られているのに対し、fluentbit は C で作られており軽量に動作します (その代わりに fluentd ほど高機能ではなく、単純なログ収集と処理, 送信くらいしかできません)

参考

最近の kubernetes ログ収集としてよく使われている印象があります https://fluentbit.io/kubernetes/

構成図

この記事では以下のような構成を立ち上げます。

image.png

通常こういったログ回収用のコンテナはサイドカーでアプリの pod に組み込んでしまうのが多いと思います。 ただ、それやるとアプリの分だけ fluentbit コンテナも立ち上がります。 いくら fluentbit が軽量とはいえ、そこまで立てる必要はないように思えるので、今回は fluentbit を daemonset として動かして、1 つのノードにつき 1 つの fluentbit で動かしてみます。

図が示す通り、アプリケーションと fluentbit で共有するボリュームを用意し、アプリケーションはそのボリュームにログを吐き出し、fluentbit はそのボリューム内のログファイルを ElasitcSearch へ転送という流れとなります。

(この構成の場合、fluentbit を立て直したりすると同じログメッセージがまた送られてしまうことがあるため、そこはもう少しつめたいところ)

単純なログ回収

まず単純なログ回収を確認します。 k8s クラスタには EKS クラスタを使います。 eksctl でたちあげときます。

eksctl create cluster --name sample-cluster \
  --version 1.14 \
  --nodegroup-name sample-cluster-worker \
  --node-type t3.medium \
  --nodes 3 \
  --nodes-min 1 \
  --nodes-max 5 \
  --managed

Go アプリケーション用意

適当にログを吐き出すアプリを作ります

package main

import (
    "log"
    "os"
    "time"
)

func main() {
    logDirectory := os.Getenv("LOG_DIRECTORY")
    // kubernetesの場合は、HOSTNAMEはPod名になるので、ユニークなファイル名として扱えます。
    hostName := os.Getenv("HOSTNAME")

    // ログファイルを開きます
    logfile, err := os.OpenFile(logDirectory+hostName+".log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666)
    if err != nil {
        panic("cannnot open logfile")
    }
    defer logfile.Close()

    // ログの吐き出し先をファイルにします
    log.SetOutput(logfile)
    log.SetFlags(log.Ldate | log.Ltime)

    // 適当にループしながらhost名を吐き出します
    for {
        log.Println(hostName)
        time.Sleep(5 * time.Second)
    }
}

manifest

これをデプロイするマニフェストを用意します

apiVersion: apps/v1
kind: Deployment
metadata:
  name: fluent-bit-sample-app
  labels:
    app: fluent-bit-sample-app
spec:
  replicas: 6
  selector:
    matchLabels:
      app: fluent-bit-sample-app
  template:
    metadata:
      labels:
        app: fluent-bit-sample-app
    spec:
      containers:
        - name: fluent-bit-sample-app
          image: esaka/fluent-bit-sample-app:v1.0.0
          ## 環境変数でログディレクトリを指定してます
          env:
            - name: LOG_DIRECTORY
              value: "/var/log/golang-app/"
          ## volumeをPod内の"/var/log/golang-app"にマウントしてます
          ## どこでもいいのですが、分かりやすいのでホストの方と合わせてます
          volumeMounts:
            - mountPath: /var/log/golang-app
              name: log-volume
      ## ここでhostPathタイプのボリュームを設定しています
      ## ホスト(つまりPodが配置されるNode)の"/var/log/golang-app"をボリュームとして読み込みます
      volumes:
        - name: log-volume
          hostPath:
            path: /var/log/golang-app
            ## ここを指定することでこのディレクトリが存在しない場合は作成してくれます
            type: DirectoryOrCreate

ポイントとなるところはコメントつけておきました。 これをデプロイします

動作確認

少し pod の中に入って、ログディレクトリを確認してみます

$ kubectl exec -it fluent-bit-sample-app-55cc97d4c7-xwx5j sh

/ ## ls /var/log/golang-app/
fluent-bit-sample-app-55cc97d4c7-4x96r.log  fluent-bit-sample-app-55cc97d4c7-xwx5j.log

/ ## cat /var/log/golang-app/fluent-bit-sample-app-55cc97d4c7-xwx5j.log
2020/03/02 08:31:45 fluent-bit-sample-app-55cc97d4c7-xwx5j
2020/03/02 08:31:50 fluent-bit-sample-app-55cc97d4c7-xwx5j
2020/03/02 08:31:55 fluent-bit-sample-app-55cc97d4c7-xwx5j

/var/log/golang-app/に自分の Pod 名でログファイルが生成されています。 また、前述の図の通り複数の pod で同じディレクトリを共有するので、同じ Node に配置された別 Pod のログファイルも作成されています

ElasticSearch の用意

ElasticSearch と Kibana はなんでもいいんですが、今回は k8s 内に立ててしまいます。

こちらに簡易的な ElasticSearch を k8s 上に立てるマニフェストをまとめてます。

fluentbit の用意

基本的に公式が提供しているマニフェストをデプロイします

git clone した後に、configmap を編集して前述の Go アプリケーションのログを回収するようにします

$ git clone https://github.com/fluent/fluent-bit-kubernetes-logging.git
$ vi fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-configmap.yaml

以下のように書き換えます

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: logging
  labels:
    k8s-app: fluent-bit
data:
  ## Configuration files: server, input, filters and output
  ## ======================================================
  fluent-bit.conf: |
    [SERVICE]
        Flush         1
        Log_Level     info
        Daemon        off
        HTTP_Server   On
        HTTP_Listen   0.0.0.0
        HTTP_Port     2020
    @INCLUDE input-applog.conf
    @INCLUDE output-elasticsearch.conf
  input-applog.conf: |
    [INPUT]
        Name        tail
        Path        /var/log/golang-app/*
  output-elasticsearch.conf: |
    [OUTPUT]
        Name            es
        Match           *
        Host            ${FLUENT_ELASTICSEARCH_HOST}
        Port            ${FLUENT_ELASTICSEARCH_PORT}
        Logstash_Format On
        Logstash_Prefix golang-app-metrics
        Replace_Dots    On
        Retry_Limit     False

Path /var/log/golang-app/*はアプリケーションで設定したログのフォルダを指定してください、ワイルドカードでそのディレクトリ以下全てのログファイルが対象になります。 Logstash_Prefix golang-app-metricsが ElasticSearch 投入時のインデックスになります。 Indexという設定でも設定可能ですがLogstash_Prefixで指定しておくと、日毎に-YYYY.MM.DDを付与してくれるので管理しやすくなります。

もし、こちらの方法で elasticsearch をデプロイした場合は fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-ds.yamlも修正します

        env:
        - name: FLUENT_ELASTICSEARCH_HOST
-          value: "elasticsearch"
+          value: "elasticsearch.default.svc.cluster.local"
        - name: FLUENT_ELASTICSEARCH_PORT
          value: "9200"

別の namespace の service は、上記のように指定しないと到達できません。


あとは README に記載されている通りにデプロイします。

$ kubectl create namespace logging
$ kubectl create -f fluent-bit-kubernetes-logging/fluent-bit-service-account.yaml
$ kubectl create -f fluent-bit-kubernetes-logging/fluent-bit-role.yaml
$ kubectl create -f fluent-bit-kubernetes-logging/fluent-bit-role-binding.yaml
$ kubectl create -f fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-configmap.yaml
$ kubectl create -f fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-ds.yaml

動作確認

Kibana につなげてみましょう

$ kubectl port-forward <kibanaのpod名> 5601

Index マネジメントのページ に繋げると, golang-app-metrics-YYYY.MM.DDが生成されています image.png

可視化したい場合はIndex パターンを生成しましょう

Index_pattern にgolang-app-metrics*を指定して作成します

これで discover ページへ行けば以下のように、ログを確認できます。 image.png

カスタムメトリクスを回収する

ここまでで単純なログを ElasitcSearch へ送ることは確認できました。 次にアプリケーションのカスタムメトリクスを fluentbit で回収する方法を書きます。

Metricbeat や Prometheus exporter などを使ったメトリクス回収の限界

Web アプリケーションなどを Go で作ったとして、クライアントからのリクエストに対する平均応答時間などのシンプルなメトリクスであれば、expvarパッケージなどを用いて、カスタムメトリクスをどこかのポートで公開して、Metricbeat などで定期的に回収してもらえば良いでしょう。

ただ、この場合は平均件数や累積値といったシンプルな値以外の回収は難しいです。 例えば、接続元のクライアント IP や、それらが何度アクセスしてきたか、リクエスト時のパラメーターはなんだったか? などを知りたい場合は、この公開方式だと回収漏れが発生してしまう可能性があります。 (この方法はある時点でのメトリクスの回収方法であるため)

json 文字列形式でログを吐き出して回収する

このようなメトリクスを回収したい場合は、処理結果をログに吐き出して、ログ回収ツールでそれらの値をメトリクスとして認識できるようにパースして、ElasticSearch などに投げる必要があります。

今回はアプリに logger 設定を変更し、json 形式でログを吐き出せるようにし fluentbit でログを回収時にパースしてメトリクスとして扱えるようにします。

logrus の導入

まずアプリ側で json ログを吐き出せるようにします。 Go の場合はlogrusを利用するのが便利です。

go get github.com/sirupsen/logrus

logger ディレクトリを用意

main.go の中で色々設定してもいいのですが、logger ディレクトリを作っておくとグローバル的に使えて便利です

$ tree .
.
├── Dockerfile
├── deployment.yml
├── go.mod
├── go.sum
├── logger
│   └── logger.go
└── main.go

プログラムは以下のようになっています

package logger

import (
	"github.com/sirupsen/logrus"
	"os"
)

// どの関数内でも使用できるようにグローバル変数にする
var Log = logrus.New()

func init() {
	logDirectory := os.Getenv("LOG_DIRECTORY")
	hostName := os.Getenv("HOSTNAME")

	// ログ設定
	Log.SetReportCaller(true)
	f, err := os.OpenFile(logDirectory+hostName+".log", os.O_WRONLY|os.O_CREATE, 0755)
	if err != nil {
		logrus.Fatal(err)
	}

	// 環境変数で指定した場合のみログファイルに吐き出すようにしておく
	// そうしないと単体テスト実行時とかにもログファイルが生成される..
	if os.Getenv("STAGE") == "PRODUCTION" {
		Log.SetOutput(f)
	} else{
		Log.SetOutput(os.Stdout)
	}


	// json形式でログを吐き出す
	Log.SetFormatter(&logrus.JSONFormatter{})
}
package main

import (
    "github.com/sirupsen/logrus"
    "fluent-bit-sample-app/logger"
    "time"
)

func main() {
    i := 0
    for {
        logger.Log.WithFields(logrus.Fields{
            // key: valueで自由にカスタムメトリクスをログで吐き出せる
            "count":  i,
            "text_sample": "fooooo",
        }).Info("sample log")
        i++
        time.Sleep(5 * time.Second)
    }
}

ログの吐き出しは以下のようになっており、ogrus.Fields{}内は自由に keyvalue で設定できます。 ここで取得したいカスタムメトリクスを設定します。

logger.Log.WithFields(logrus.Fields{
    "count":  i,
    "text_sample": "fooooo",
}).Info("sample log")

これで動かすと、以下のようなログが吐き出されます。

{
  "count": 0,
  "file": "main.go:16",
  "func": "main.main",
  "level": "info",
  "msg": "sample log",
  "text_sample": "fooooo",
  "time": "2020-03-02T18:22:24+09:00"
}

これを使うようにマニフェストを直してデプロイしておきます。 ついてでにログディレクトリも分けておきます。

        - name: fluent-bit-sample-app
-          image: esaka/fluent-bit-sample-app:v1.0.0
+          image: esaka/fluent-bit-sample-app:v1.0.1

          env:
            - name: LOG_DIRECTORY
-              value: "/var/log/golang-app/"
+              value: "/var/log/golang-app2/"
+            - name: STAGE
+              value: "PRODUCTION"
-            - mountPath: /var/log/golang-app
+            - mountPath: /var/log/golang-app2
              name: log-volume

          hostPath:
-            path: /var/log/golang-app
+            path: /var/log/golang-app2

fluentbit の設定変更

次に fluentbit の設定を変更します。 今までと同じ設定で動かしていると、json ログを json 文字列として送るだけになって、ES,Kibana でメトリクスの管理ができません。 そのため、fluentbit に Parser を入れて、json ログを json として解釈できるようにする必要があります。

一度古いのは削除しておきます

$ kubectl delete -f fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-configmap.yaml
$ kubectl delete -f fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-ds.yaml

configmap を以下のように書き換えます 新たに Parser というのを追加して、input と output の間に挟むようにしています。

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluent-bit-config
  namespace: logging
  labels:
    k8s-app: fluent-bit
data:
  ## Configuration files: server, input, filters and output
  ## ======================================================
  fluent-bit.conf: |
    [SERVICE]
        Flush         1
        Log_Level     info
        Daemon        off
        HTTP_Server   On
        HTTP_Listen   0.0.0.0
        HTTP_Port     2020
        Parsers_File  parsers.conf
    @INCLUDE input-applog.conf
    @INCLUDE filter-applog.conf
    @INCLUDE output-elasticsearch.conf
  input-applog.conf: |
    [INPUT]
        Name        tail
        Path        /var/log/golang-app2/*
  filter-applog.conf: |
    [FILTER]
        Name         parser
        Parser       jsonparser
        Match        *
        Key_Name     log
        Reserve_Data On
        Preserve_Key On
  output-elasticsearch.conf: |
    [OUTPUT]
        Name            es
        Match           *
        Host            ${FLUENT_ELASTICSEARCH_HOST}
        Port            ${FLUENT_ELASTICSEARCH_PORT}
        Logstash_Format On
        Logstash_Prefix golang-app-metrics
        Replace_Dots    On
        Retry_Limit     False
  parsers.conf: |
    [PARSER]
        Name        jsonparser
        Format      json
        Time_Key    time
        Time_Format %Y-%m-%dT%H:%M:%S
        Time_Keep    On
        ## Command      |  Decoder | Field | Optional Action
        ## =============|==================|=================
        Decode_Field_As   escaped_utf8    log    do_next
        Decode_Field_As   json       log

これでデプロイし直します。

$ kubectl apply -f fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-configmap.yaml
$ kubectl apply -f fluent-bit-kubernetes-logging/output/elasticsearch/fluent-bit-ds.yaml

動作確認

discover ページに行くと以下のように、count, text_sampleといった、先ほど logrus で設定したカスタムメトリクスがちゃんとメトリクスとして解釈されています。

image.png

メトリクスなので、以下のようにグラフにしたりもできます。

image.png

timestamp をうまく解釈できない場合

環境によっては timestamp がうまく解釈できない場合があります。 parser のTime_Format %Y-%m-%dT%H:%M:%Sでフォーマットを指定しているのですが、JST だと環境によってはうまくパースできませんでした(よくあるやつですが、実際の時間の 9 時間後に@timestampが設定されてしまうとか)

そういう場合は、一度 index pattern を作り直して、時刻のキーとして扱うのを@timestampでなくtimeにすればうまくいったりします。

image.png

まとめ

fluentbit を使って、go アプリのカスタムメトリクスを回収してみました。

今回は daemonset で動かしてみましたが、サイドカーで動かすと k8s モジュールが使えて、Pod 名とかのメタデータをログに付与したりできるそうなので、リソースを気にしない場合はそっちもありかもです。

あと今回は ElasticSearch を使いましたが、ログ収集先としては最近 Loki とかがいいんですかね?そっちも今後調べてみたいです。