索引しないという選択
一言でいうと
Lokiはログの本文をインデックス化せず、ラベルの集合だけをインデックス化します。そのため保存が安く、そのかわり検索は「ラベルで候補を絞ってから本文をスキャンする」方式になります。
なぜ必要なのか
Elasticsearch/OpenSearchは、ログのすべてのトークンを転置インデックスに入れます。そのため「errorという単語を含むログ」を即座に見つけられます。代償は大きいです。インデックスが元データより大きくなることがよくあり、インデックス作成の作業そのものがCPUとメモリを大量に使います。
Lokiは違う問いを立てました。ログを探すとき、実際にはどうしているか、という問いです。
たいていは、こうです。「決済サービスの、本番の、直近30分」で範囲を絞り、その中で文字列を探します。前半の絞り込みはラベル数個で済み、後半のスキャンは、その範囲が小さければ速いです。
そのためLokiは、ラベルだけをインデックス化し、本文は圧縮して塊(チャンク)として保存します。Prometheusと同じラベルモデルを使うのも意図的です。メトリクスで異常を見つけて、同じラベルでログへ移れます。
どう動くのか
{namespace="labhub-prod", app="labhub"} |= "error" | json | status >= 500
^--------- 라벨 셀렉터 (색인됨) -------^ ^--- 본문 필터 (훑음) ---^
前の波括弧がストリームを選びます。ラベルの組み合わせ1つが、ストリーム1つです。後ろのパイプが、そのストリームたちの本文を順番にスキャンします。
性能は、まったく前でどれだけ絞るかにかかっています。ラベルセレクターがストリーム10個を選べば速く、10万個を選べば遅くなります。
カーディナリティ: Lokiではより致命的です
Prometheusでラベルのカーディナリティが問題になることは、よく知られています。Lokiではもっと悪くなります。
ラベルの組み合わせ1つごとに、別のストリームと別のチャンクファイルができます。trace_idやuser_idをラベルに入れると、ストリームが数百万個になり、それぞれが小さなチャンクファイルを作ります。ストレージが小さなファイルで埋め尽くされ、クエリはそれらのファイルをすべて開かなければなりません。
ルール: ラベルは、値の種類が少なく、あまり変わらないものだけにします。
| ラベルに向いているもの | ラベルに向いていないもの |
|---|---|
| namespace, app, pod, level, env | trace_id, user_id, request_id, ip, タイムスタンプ |
向いていないものは本文に残し、必要なときに|=や| jsonで探します。それがLokiの設計意図です。
OpenSearchとの分かれ目
| 質問 | どちらが向いているか |
|---|---|
| 「直近30分のこのPodのエラー」 | Loki。ラベルで絞れて、範囲が小さいためです |
| 「直近3か月全体でこの注文番号」 | OpenSearch。絞るラベルがなく、範囲が大きいためです |
| 「エラーメッセージ別の頻度上位20」 | OpenSearch。集計が必要なためです |
| 「メトリクスで見た異常時点のログ」 | Loki。Prometheusとラベルが同じためです |
2つは競合関係ではなく、異なる問いに答えます。実際に、両方を置いている現場が多いです。Lokiで最近のものを安く、OpenSearchで古いものを調査できるようにしています。
よくある勘違い
「Lokiは無条件に安い」という考え: ラベルを間違って付けると、OpenSearchより悪くなることがあります。ストリームの爆発は元に戻すのも難しいです(古いチャンクがそのまま残ります)。
「grepで十分」という考え: ログが複数のノードに散らばっていて、Podが落ちると消えます。集める理由は検索ではなく保持です。
何を残し、何を捨てるか
ログは、3つのシグナルの中で最も安く作られ、最も高く保管されます。そのため、収集の段階で量を減らす判断をしないと、コストであれ検索速度であれ、いつか問題になります。
捨ててよいものから捨てます。正常なリクエスト1件ごとに残すアクセスログは、たいていメトリクスで代替できます。毎秒のリクエスト数とステータスコードの分布は、メトリクスのほうがはるかに安く答えられるため、ログは正常な流れから外れたものに集中するほうがよいです。ヘルスチェックやレディネス確認のように毎秒何度も記録されるものは、特に先に除外する候補です。
同じ行が繰り返されるならまとめます。リトライのループに陥ったコンポーネントが毎秒数百行を吐き出すことはよくあり、その行はすべて同じ内容です。アプリケーション側で同じメッセージの繰り返しを数えて1行に要約すれば、量が桁違いに減ります。
保持期間を等級に分けます。すべてを90日置くより、大部分は短く、監査が必要なものだけを長く置くほうが、はるかに安くなります。ただし、どれがどの等級かをラベルで区別できる必要があり、それが前に見たラベル設計とつながります。
そして、ログの形式が、そのままあとの検索コストになります。構造化された形で残せば、パースが1回で終わり、条件で絞れますが、人が読みやすい文章だけで残すと、毎回文字列をスキャンしなければなりません。1行に何を入れるかをあらかじめ決めておくことがここでも答えであり、前にトレーシングを扱いながら述べた4つ、つまりリクエスト識別子、デプロイバージョン、処理時間、失敗したときの対象名が、ログでもそのまま有効です。
最後に、ログが個人情報を含んでいないかを、一度は点検する必要があります。リクエスト本文をまるごと残すコードがどこかにあれば、そのログ全体が別の等級の資産になり、保持期間とアクセス権限をそれに合わせて決め直す必要があります。
実務で本当に大切なこと
Lokiのクエリが遅いときに最初に見るのは、選択されたストリーム数です。
count(count by (pod) ({namespace="labhub-prod"}))
この値が大きければ、ラベルセレクターをもっと絞るか、そもそもラベル設計が間違っているかです。クエリのチューニングではなく、収集設定を直す必要があります。