Enjoy Architecting

Twitter: @taisho6339

最短で理解して運用するGrafana Loki

本記事について

Lokiについてまったく知識のない状態の人にとって、1からキャッチアップしていくのは とても大変なことです。

特にLokiはマイクロサービスで構成されているため、何を知るべきなのかの全体像が見えにくいと思っています。

そのため、Lokiをまったく知らない状態から実際に運用検証を開始するために必要なインプットを体系的にまとめました。

具体的には下記の項目で整理します。

  1. Lokiの機能
  2. Lokiを構成するアーキテクチャ
  3. Lokiを構成するプロセス
  4. Lokiのモニタリング
  5. Lokiでのログのリテンション管理
  6. Lokiのデプロイ
  7. Lokiでのデータキャッシュ
  8. Lokiのベストプラクティス

※前提として、Prometheusについての基本的な知識があれば本記事についてもすぐに理解できるかと思います。

1. Lokiの機能

Grafana Lokiとは?

Lokiは3大監視項目である、メトリクス、ログ、トレースのうち、ログを担当するモニタリングツールです。

メトリクス収集のPrometheus、時系列データベースのCortexのアーキテクチャを参考に作られた分散システムの構成になっています。

Grafana Lokiでは何ができるのか?

Lokiの機能としては主には下記のようなことができます。

  • Grafanaと連携してダッシュボードでのログの可視化や検索
  • ログベースでのアラートルールの設定
  • ログデータのマルチテナント管理

注意点としてはマルチテナント前提に設計されてはいるものの、Lokiそのものにテナント認証機能はありません。 よってLokiに保存したり検索をかける前に、テナント認証のプロキシをはさみ、テナント識別用のHTTPリクエストヘッダーを埋め込む、といったことが必要になります。

Authentication

Lokiの主な特徴

Lokiは下記の様な特徴を持ったツールです。

  • Prometheusと同様1つ1つのログデータがラベルを持つ

  • Cortexと同様、書き込み、読み込み、アラーティング、データ圧縮など複数の役割を持った分散システム構成になっている

  • Cortexと同様自分ではデータストレージを持たず、AWSGCPのObject StorageやBigtable、DynamoDB、他のOSSソフトウェアなどを使ってデータを管理する

役割ごとにプロセスを分割しているため柔軟にスケールでき、可用性や信頼性もクラウドプロバイダーなどに移譲できるというのが大きな特徴です。

2. Lokiのアーキテクチャ

Lokiのアーキテクチャは図のようになっています。 データには、転置インデックスである「Index」と実データである「Chunk」の2種類が存在し、そのデータを書き込んだり、読み込んだり、定期的にチェックしてアラートを飛ばしたり、圧縮したりリテンションを管理するプロセスが存在しているという構成になります。 また、データにはキャッシュ機構も存在しています。

Lokiが扱うデータ

Lokiは受け取ったログデータから、Chunk(実ログデータ)とIndex(検索用転置インデックス)を生成し、 設定、連携されたログデータストレージに保存します。 ただ、ログデータストレージにはなんでも指定できるわけではなく、サポートされているものが決まっています。 また、ChunkとIndexで指定できるストレージも異なります。

Loki Storage

以前は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を用いてクラスタメンバー間でクラスタ情報の更新を行っています。

memberlist

Ingester

ログを実際に保存する役割を担っています。

受け取ったログのラベルセット + テナントのIDを見て、対応するChunkにログを追加します。 また、もしChunkが存在しない場合は新規で作成します。

Chunkに仕分けられたログは一定時間メモリにバッファされ、一定のタイミングで永続化ストレージにflushされていきます。

この構成だと一定期間ログデータをメモリに置いた状態なので、この間にプロセスがダウンするとデータが揮発してしまいます。 よってWrite Ahead Logという仕組みを用いることで復旧できるようにしています。

WAL

Ingesterは書き込みリクエストを受け取ると、永続化領域にまずログを記録します。

flushしたものはflushしたことがわかるように更新されるため、もしプロセスダウンで復活したときでも、 どのログがflushされていないのかを判別することができ、復旧することができます。

Write Ahead Log

しかし注意点があります。

ログ書き込み用のディスクがfullになり、WALに書き込めない状態でもログの書き込みリクエストはエラーにならずに受信できてしまいます。

つまりWALに書き込まれずにログを処理する時間が発生する可能性があり、 この時間でプロセス停止などが意図せず起こればログデータを失う可能性があります。

ディスク使用量や、WALへの書き込み失敗数などをモニタリングし、検知できるようにしておくことが大切です。

クラスタリング

IngesterはDistributorからConsitent Hashによるルーティングがされるため、 Consistent HashのRingを保存しておく共通のデータストアなどが必要になります。

実際にクラスタリングに使えるのはDistributerで列挙したものと同様です。

実際の書き込みフロー

Distributorがリクエストを受け付けてから、Ingesterが処理するまでの流れはここに詳細に記載されています。

Write Path

Querier

LogQLの形式でクエリを受け取り、検索処理するコンポーネントになります。 具体的に、検索処理がどのようなフローで行われるのかは下記がわかりやすいです。

Read Path

Querier Frontend

Querierの前にProxyとして置くことができるOptionalなコンポーネントです。 主な役割として、パフォーマンス向上のために存在しています。

機能としては、検索結果のキャッシュ、クエリ処理のキューイング、大きなクエリの分割を提供します。

Querier Frontend

巨大なクエリを実行したときに困るのはOOMです。 よってQuerier Frontendのレイヤで小さなクエリに分割し、キューイングして別々のQuerierに分散させることで、一つのQuerierに大きな負荷がかかることを回避することができます。 Querierから返ってきた結果はこのレイヤで統合されて、最終的なレスポンスを返します。

Ruler

Querierに定期的にクエリ発行して、AlertManagerにアラートを飛ばすことのできるコンポーネントです。

クエリそのものはLogQLという形式ですが、Prometheusと同じようなフォーマットでルールを記載することができます。

Alerting

metricsの出力機能がなかったり、exporterが提供されていないようなプロダクトに対してもログベースで気軽にアラート設定を行うことができます。

Compactor

Compactorについてはあまり情報がありませんでしたが、一定周期でIndexデータを最適に圧縮してくれるコンポーネントのようです。

compactor_config

Table Manager

Lokiはストレージのバックエンドとして、DynamoDBやBigTableなどの、テーブルベースのDBをサポートしています。 Table ManagerはそういったテーブルベースのDBに対して、スキーマの変更、バージョン管理や、データのリテンション管理を行うことができます。

Table Manager

注意点として、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になるようです。 ※この辺りの理由は調査中

Upgrade Guide

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のレイヤでそれぞれでキャッシュを保持します。 例えば、Ingesterは前述したとおり、データを一時的にメモリに置き、期限が切れたタイミングでWriteBackします。 また、Querier Frontendは検索結果のキャッシュを保持しています。 このキャッシュのバックエンドには、Redis、memcache、in-memoryを指定でき、 キャッシュの影響でパフォーマンスが大きく変わるため、チューニングの余地があります。

8. Lokiのベストプラクティス

ラベルのカーディナリティに配慮する

Lokiのログデータも、Prometheusと同様にラベルをつけることで検索に役立てることができますが、 同様にカーディナリティに配慮する必要があります。 カーディナリティとは「何種類の値を取りうるか」での数値で、これがあまりにも膨大、もしくは予測できない場合、チャンクのサイズも膨大になってしまい、ストレージ容量の消費とロードにかかる時間がボトルネックになってしまいます。 Lokiでは、経験則的に、1桁 ~ 10台の値に押さえておくように推奨されています。 これ以上にカーディナリティの高い、もしくは予測ができない無制限のパラメータはラベルではなく、文字列一致や、正規表現のパターンマッチなどを使うことが推奨されています。

まとめ

ここまでの内容を踏まえることで、Lokiは実際に自分たちの環境、要件にフィットするのかを検証することができるようになったはずです。

まだまだ情報不足であり、運用が難しいプロダクトだとは思いますが、本記事がお役に立てれば幸いです。

追記

こちらのkubenews 第20回でDemoを交えつつ、紹介させていただきました www.youtube.com

また、動画内のデモはこちらのmanifestを使うことで再現することができます github.com