fluentbitを使ってk8s上のGoアプリのカスタムメトリクスを回収する
March 2, 2020
この記事でやること
この記事では、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 でちゃんとメトリクスとして扱えるように送信します。
fluentbit とは
fluentd などを開発している Treasure Data が開発しているログ収集ツールです。 fluentd が Ruby で作られているのに対し、fluentbit は C で作られており軽量に動作します (その代わりに fluentd ほど高機能ではなく、単純なログ収集と処理, 送信くらいしかできません)
最近の kubernetes ログ収集としてよく使われている印象があります https://fluentbit.io/kubernetes/
構成図
この記事では以下のような構成を立ち上げます。
通常こういったログ回収用のコンテナはサイドカーでアプリの 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
が生成されています
可視化したい場合はIndex パターンを生成しましょう
Index_pattern にgolang-app-metrics*
を指定して作成します
これで discover ページへ行けば以下のように、ログを確認できます。
カスタムメトリクスを回収する
ここまでで単純なログを 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 で設定したカスタムメトリクスがちゃんとメトリクスとして解釈されています。
メトリクスなので、以下のようにグラフにしたりもできます。
timestamp をうまく解釈できない場合
環境によっては timestamp がうまく解釈できない場合があります。
parser のTime_Format %Y-%m-%dT%H:%M:%S
でフォーマットを指定しているのですが、JST だと環境によってはうまくパースできませんでした(よくあるやつですが、実際の時間の 9 時間後に@timestamp
が設定されてしまうとか)
そういう場合は、一度 index pattern を作り直して、時刻のキーとして扱うのを@timestamp
でなくtime
にすればうまくいったりします。
まとめ
fluentbit を使って、go アプリのカスタムメトリクスを回収してみました。
今回は daemonset で動かしてみましたが、サイドカーで動かすと k8s モジュールが使えて、Pod 名とかのメタデータをログに付与したりできるそうなので、リソースを気にしない場合はそっちもありかもです。
あと今回は ElasticSearch を使いましたが、ログ収集先としては最近 Loki とかがいいんですかね?そっちも今後調べてみたいです。