最短で理解して運用するGrafana Loki
本記事について
Lokiについてまったく知識のない状態の人にとって、1からキャッチアップしていくのは
とても大変なことです。
特にLokiはマイクロサービスで構成されているため、何を知るべきなのかの全体像が見えにくいと思っています。
そのため、Lokiをまったく知らない状態から実際に運用検証を開始するために必要なインプットを体系的にまとめました。
具体的には下記の項目で整理します。
- Lokiの機能
- Lokiを構成するアーキテクチャ
- Lokiを構成するプロセス
- Lokiのモニタリング
- Lokiでのログのリテンション管理
- Lokiのデプロイ
- Lokiでのデータキャッシュ
- Lokiのベストプラクティス
※前提として、Prometheusについての基本的な知識があれば本記事についてもすぐに理解できるかと思います。
1. Lokiの機能
Grafana Lokiとは?
Lokiは3大監視項目である、メトリクス、ログ、トレースのうち、ログを担当するモニタリングツールです。
メトリクス収集のPrometheus、時系列データベースのCortexのアーキテクチャを参考に作られた分散システムの構成になっています。
Grafana Lokiでは何ができるのか?
Lokiの機能としては主には下記のようなことができます。
- Grafanaと連携してダッシュボードでのログの可視化や検索
- ログベースでのアラートルールの設定
- ログデータのマルチテナント管理
注意点としてはマルチテナント前提に設計されてはいるものの、Lokiそのものにテナント認証機能はありません。
よってLokiに保存したり検索をかける前に、テナント認証のプロキシをはさみ、テナント識別用のHTTPリクエストヘッダーを埋め込む、といったことが必要になります。
Lokiの主な特徴
Lokiは下記の様な特徴を持ったツールです。
-
Prometheusと同様1つ1つのログデータがラベルを持つ
-
Cortexと同様、書き込み、読み込み、アラーティング、データ圧縮など複数の役割を持った分散システム構成になっている
-
Cortexと同様自分ではデータストレージを持たず、AWSやGCPのObject StorageやBigtable、DynamoDB、他のOSSソフトウェアなどを使ってデータを管理する
役割ごとにプロセスを分割しているため柔軟にスケールでき、可用性や信頼性もクラウドプロバイダーなどに移譲できるというのが大きな特徴です。
2. Lokiのアーキテクチャ
Lokiのアーキテクチャは図のようになっています。
データには、転置インデックスである「Index」と実データである「Chunk」の2種類が存在し、そのデータを書き込んだり、読み込んだり、定期的にチェックしてアラートを飛ばしたり、圧縮したりリテンションを管理するプロセスが存在しているという構成になります。
また、データにはキャッシュ機構も存在しています。
Lokiが扱うデータ
Lokiは受け取ったログデータから、Chunk(実ログデータ)とIndex(検索用転置インデックス)を生成し、
設定、連携されたログデータストレージに保存します。
ただ、ログデータストレージにはなんでも指定できるわけではなく、サポートされているものが決まっています。
また、ChunkとIndexで指定できるストレージも異なります。
以前はIndexに関してはObject Storageへの保存がサポートされていませんでしたが、現在はBoltDBというローカルDBに一旦保存し、BoltDB Shipperという仕組みを使うことで、自動的にObject Storageへ同期してくれるようになりました。
3. Lokiを構成するプロセス
Lokiの主要なプロセスとして下記が挙げられます。
- Distributor
- Ingester
- Querier
- Querier Frontend
- Ruler
- Compactor
- Table Manager
このうち、Lokiを最低限動かすのに必要なプロセスはDistributor、Ingester、Querierです。
他のプロセスは、機能的に必要な場合やパフォーマンス改善で必要な場合に足していく形になります。
では実際に各プロセスについて役割を追っていきます。
Distributor
書き込みリクエストを最初にハンドリングするコンポーネントで、受け取ったログを適切なIngesterへつなぎます。
IngesterへのルーティングはConsistent Hashアルゴリズムを用いてルーティングします。
ハッシュの計算にはlogのラベルとtenant IDを用い、計算されたハッシュ値より大きくて一番近い値を持つIngesterへルーティングされます。
また、バリデーション、データ加工、Rate Limitの役割も担っており、
特にRate Limitは全体のRate LimitをDistributorの台数で割った値を一台あたりのRate Limitに設定します。
よってロードバランサーを置いて、均等にトラフィックを分散するのが有効です。
レプリケーション
データ保護、及びIngesterの入れ替わりに対応するため、
通常複数のIngesterに複製してログを送ります。(デフォルトでは3台に送る)
また、データの一貫性を保つため、書き込み完了の判定にはquorumを用いています。
quorumは、
floor(replication_factor / 2) + 1
で計算され、例えば送信先が3台あったら2台に書き込みが成功しないと書き込み失敗になります。
また複製として使用するIngesterは、最初にハッシュ値がヒットしたIngesterから、Consistent HashのRing上を時計回りに順番にレプリカ数分ピックアップします。
バリデーション
バリデーションプロセスでは以下のような内容をチェックしており、バリデーションに失敗すると書き込みエラーを返します。
- ログの時系列はあっているか?
- ログは大きすぎないか?
- Prometheus形式のラベルになっているか?
特に時系列の概念は重要で、最後に受け取ったログのtimestampより前のtimestampのログは受け取ることができません。
詳しくこちらに記載されています。
Logs must be in increasing time order per stream
データ加工
ラベルを元にハッシュ値を作るので、同じ構成のラベルは同じハッシュ値になるようラベルの並び順をソートして正規化しています。
たとえば、これらのラベルが同じハッシュ値になるように並び順を揃えます。
{job="syslog",env="dev"}
{env="dev",job="syslog"}
Rate Limit
Rate Limitでは受け取る書き込みリクエストを制限します。
リクエストの制限は1つのテナントごとに設定され、
1つのDistributorが制限するRate Limitは、そのテナントに対するLimitをDistributorの数で割ったものになります。
これを実現するためには、Distributorが全体で何台いるのかを各メンバーが知る必要があり、
そのためにクラスタリングしています。
クラスタ情報の保管、管理には、Consul, etcd, memberlist, inmemoryのオプションを選択できます。
memberlistはHashCorp製のクラスタ管理用ライブラリで、Consul等と同様にgossip-protocolを用いてクラスタメンバー間でクラスタ情報の更新を行っています。
Ingester
ログを実際に保存する役割を担っています。
受け取ったログのラベルセット + テナントのIDを見て、対応するChunkにログを追加します。
また、もしChunkが存在しない場合は新規で作成します。
Chunkに仕分けられたログは一定時間メモリにバッファされ、一定のタイミングで永続化ストレージにflushされていきます。
この構成だと一定期間ログデータをメモリに置いた状態なので、この間にプロセスがダウンするとデータが揮発してしまいます。
よってWrite Ahead Logという仕組みを用いることで復旧できるようにしています。
WAL
Ingesterは書き込みリクエストを受け取ると、永続化領域にまずログを記録します。
flushしたものはflushしたことがわかるように更新されるため、もしプロセスダウンで復活したときでも、
どのログがflushされていないのかを判別することができ、復旧することができます。
しかし注意点があります。
ログ書き込み用のディスクがfullになり、WALに書き込めない状態でもログの書き込みリクエストはエラーにならずに受信できてしまいます。
つまりWALに書き込まれずにログを処理する時間が発生する可能性があり、
この時間でプロセス停止などが意図せず起こればログデータを失う可能性があります。
ディスク使用量や、WALへの書き込み失敗数などをモニタリングし、検知できるようにしておくことが大切です。
クラスタリング
IngesterはDistributorからConsitent Hashによるルーティングがされるため、
Consistent HashのRingを保存しておく共通のデータストアなどが必要になります。
実際にクラスタリングに使えるのはDistributerで列挙したものと同様です。
実際の書き込みフロー
Distributorがリクエストを受け付けてから、Ingesterが処理するまでの流れはここに詳細に記載されています。
Querier
LogQLの形式でクエリを受け取り、検索処理するコンポーネントになります。
具体的に、検索処理がどのようなフローで行われるのかは下記がわかりやすいです。
Querier Frontend
Querierの前にProxyとして置くことができるOptionalなコンポーネントです。
主な役割として、パフォーマンス向上のために存在しています。
機能としては、検索結果のキャッシュ、クエリ処理のキューイング、大きなクエリの分割を提供します。
巨大なクエリを実行したときに困るのはOOMです。
よってQuerier Frontendのレイヤで小さなクエリに分割し、キューイングして別々のQuerierに分散させることで、一つのQuerierに大きな負荷がかかることを回避することができます。
Querierから返ってきた結果はこのレイヤで統合されて、最終的なレスポンスを返します。
Ruler
Querierに定期的にクエリ発行して、AlertManagerにアラートを飛ばすことのできるコンポーネントです。
クエリそのものはLogQLという形式ですが、Prometheusと同じようなフォーマットでルールを記載することができます。
metricsの出力機能がなかったり、exporterが提供されていないようなプロダクトに対してもログベースで気軽にアラート設定を行うことができます。
Compactor
Compactorについてはあまり情報がありませんでしたが、一定周期でIndexデータを最適に圧縮してくれるコンポーネントのようです。
Table Manager
Lokiはストレージのバックエンドとして、DynamoDBやBigTableなどの、テーブルベースのDBをサポートしています。
Table ManagerはそういったテーブルベースのDBに対して、スキーマの変更、バージョン管理や、データのリテンション管理を行うことができます。
注意点として、S3などのObject Storageを使う場合は、Table Managerのスコープ外なので、S3側でLifecycle設定を通してリテンション管理を行うなどが別途必要になります。
Lokiのプロセス実行モードについて
Lokiはすべてのプロセスを一つのバイナリでまとめて実行するモード(モノリシックモード)と、マルチプロセスに構成するモード(マイクロサービスモード)があります。
モノリシックモードは、簡単にプロセスを立ち上げてすぐに機能を検証することが可能ですが、柔軟なスケールができません。
すべて一緒にスケールしないといけないので、例えば読み込み用のプロセスだけスケールするなどできず、リソース上の無駄が発生したりします。
そのため、モノリシックモードは検証やスモールスタート用に用いるのが推奨されており、ある程度の規模の本番環境ではマイクロサービスモードで運用するのがStandardのようです。
StatelessなプロセスとStatefulなプロセス
LokiにおいてStatefulなプロセスは、IngesterとQuerierです。
IngesterはWALやChunkを一定期間ローカルにバッファする性質上、Statefulなのは明白ですが、Querierに関してはboltdb-shipperを利用してIndexを保存している場合にStatefulになるようです。
※この辺りの理由は調査中
4. Lokiのモニタリング
Lokiの各プロセスはPrometheus形式のmetricsを出力します。
よって、汎用的なプロセスの死活監視などを行いつつ、Lokiのmetricsを見て詳細な機能に関するモニタリングを行うことになります。
前述したWALに関するメトリクスは、Observabilityに記載されてはいませんでしたが、実装には記述されていました。
5. Lokiでのログのリテンション管理
前述した通り、TableベースのDBをバックエンドにする場合は、TableManagerを使ってリテンション管理を設定するのが良いです。
そうでなければストレージそのものに備わっている機能を使って管理するか、自前で仕組みを作る必要があります。
6. Lokiのデプロイ
幸いHelmチャートが用意されているので、基本的にはこれを使うと良いです。
7. Lokiのデータキャッシュ
Lokiではアーキテクチャ図にもある通り、Ingester、Querier、Querier Frontend、Rulerのレイヤでそれぞれでキャッシュを保持します。
キャッシュのバックエンドには、Redis、memcache、in-memoryを指定でき、
このキャッシュの影響でパフォーマンスが大きく変わるため、チューニングの余地があります。
8. Lokiのベストプラクティス
ラベルのカーディナリティに配慮する
Lokiのログデータも、Prometheusと同様にラベルをつけることで検索に役立てることができますが、
カーディナリティに配慮する必要があります。
カーディナリティとは「何種類の値を取りうるか」の数値で、これがあまりにも膨大、もしくは予測できない場合、チャンクのサイズも膨大になってしまい、ストレージ容量の消費とロードにかかる時間がボトルネックになってしまいます。
Lokiでは経験則的に、1桁 ~ 10台の値に押さえておくように推奨されています。
これ以上にカーディナリティの高い、もしくは予測ができない無制限のパラメータはラベルではなく、文字列一致や、正規表現のパターンマッチなどを使うことが推奨されています。
まとめ
ここまでの内容を踏まえることで、Lokiは実際に自分たちの環境、要件にフィットするのかを検証することができるようになったはずです。
まだまだ情報不足であり、運用が難しいプロダクトだとは思いますが、本記事がお役に立てれば幸いです。
Discussion
Lokiに関して、日本語でまとまっているものが少ないのでこういうまとめがあるといいですよね。すごく助かる人が多いと思います。
読んでいて、一点気づきがありましたので指摘させてください。
上記についてですが、本文全体の引用を見る限り、恐らくLoki label best practices要約されたものだと思います。
Prometheusと同様に静的ラベルの使用は推奨するが、動的ラベルの使用については必要最低限にするというのが本文の内容かなと思います。
ref:https://grafana.com/docs/loki/latest/best-practices/#static-labels-are-good
上記のように静的ラベルの使用は推奨されていて、LokiはPrometheusと似たデータ構造を保つため、LokiでもPrometheusの時に発生していた動的ラベルによるHigh Cardinalityの問題が発生するので動的ラベル(より正確には、無制限に値を持つ可能性のあるラベル)の使用を控えた方がいいという内容かと思います。
参考:無制限に値を持つ可能性のあるラベルについて本文中で言及してる箇所は以下。
ref:https://grafana.com/docs/loki/latest/best-practices/#label-values-must-always-be-bounded
ちょっと長くなりましたが、指摘内容については以上になります。
記事については、全体的にすごくコンパクトにまとまっていていいまとめだなと思いました。
ありがとうございます、こういったコメント非常に助かります!
たしかに仰る通り大分本家とニュアンスが変わってしまっておりましたので、修正させていただきましたmm
修正された内容を確認しましたが、すごく分かりやすかったです。
修正ありがとうございました。