[RFC] Cluster Best Practices: An automated advisor for proper OpenSearch usage · Issue #23468 (#OpenSearch community, your input and insight would be greatly appreciated)
#opensource #OpenSearchAmbassador
OpenSearch#23468
[RFC] Cluster Best Practices: An automated advisor for proper OpenSearch usage · Issue #23468 (#OpenSearch community, your input and insight would be greatly appreciated)
#opensource #OpenSearchAmbassador
OpenSearch#23468
3 likes
Lucene 10.6.0 の doc-values updates は列ごとの書き直しをどうやめたのか
はじめに
OpenSearch や Elasticsearch では、検索したいデータを JSON のドキュメント(doc)として index に保存します。
doc は title や price のような field の集まりで、OpenSearch や Elasticsearch はこの field の値を使って検索、ソート、集計をします。
この index を実際にディスクに書き、検索するのが、Java で書かれた検索ライブラリの Apache Lucene です。
Lucene は index を segment という単位のファイル群に分けて書き、一度書いた segment は変更しません。
こうして保存する doc には、一部の field だけが頻繁に変わるものがよくあります。
EC の在庫数や価格、記事の閲覧数、ランキング用の人気スコアなどです。
doc 本体には本文や 512 次元の vector が入っていて、変わるのは 8 バイトの数値 1 つだけ、という形です。
新しい doc は新しい segment に書かれ、既存の segment のファイルはそのまま残ります。
Lucene でこの数値を変える方法は 2 つあります。
1 つは IndexWriter#updateDocument で doc ごと入れ直す方法で、本文も vector も index し直すことになります。
もう 1 つは IndexWriter#updateNumericDocValue で、doc-values の値だけを書き換える方法です。
doc-values は、Lucene が field の値を doc ID 順に並べて列として持つ構造です。
全文検索に使う転置インデックスは「語から doc」を引く向きの構造で、doc-values はその逆の「doc から値」を引く向きです。
ソート、ファセット、集計は、この列を doc ID で引いて値を読みます。
OpenSearch や Elasticsearch の mapping にある doc_values は、この列を作るかどうかの設定で、text 以外の多くの field では default で有効です。
updateNumericDocValue は doc を入れ直さないので、軽い操作に見えます。
ところが Lucene 10.5 までの doc-values update は、別のものを丸ごと書き直していました。
更新した doc の数に関係なく、その field の列(column)を segment 1 つぶん書き直すのです。
2026 年 10 月 29 日にリリースされた Apache Lucene 10.6.0 で、この書き直しをやめる仕組みが入りました。
CHANGES.txt の New Features には次のように書かれています。
GITHUB#16418: Incremental doc-values updates. A set-only doc-values update (NUMERIC or BINARY) is written as a sparse "delta" overlay holding just the updated documents and layered over the base column at read time, instead of rewriting the whole column, turning per-update write amplification from O(column) into O(updated docs). Opt-in; enable with IndexWriterConfig#setMaxDocValuesOverlays(n).
気になるのは、書き込み量がどれだけ減り、代わりに何を払うのか、そして OpenSearch と Elasticsearch にどう関わるのかです。
元になった Pull Request #16418 と、10.5.0 と 10.6.0 のソースを読んで確かめました。
!
本文のクラス名とメソッド名は Lucene 10.5.0 と 10.6.0 のタグのものです。
issue や PR の状態は 2026 年 11 月 1 日時点で確認しました。
setMaxDocValuesOverlays には @lucene.experimental が付いており、今後挙動が変わる可能性があります。
1 doc を更新すると何が書かれるのか
gen ファイルに書かれる列
doc-values の列も segment の一部なので、segment と同じく一度書いたら変更しません。
値を更新するには、別のファイルを書き足す必要があります。
Lucene はこの書き足しを generation(gen)という番号で管理しています。
doc-values を更新すると、その field について新しい gen のファイルを書き、読み取り時には最新の gen を使う仕組みです。
10.5.0 の ReadersAndUpdates#handleDVUpdates では、次の gen 番号を 36 進数の文字列にしてファイル名の suffix にしています。1
final long nextDocValuesGen = info.getNextDocValuesGen();
final String segmentSuffix = Long.toString(nextDocValuesGen, Character.MAX_RADIX);
問題は新しい gen ファイルの中身です。
同じメソッドの中で、MergedDocValues が既存の列の値と更新値を doc ID 順に混ぜ合わせ、その結果を新しい gen として書き出します。
更新されなかった doc の値も含めて、列全体です。
新しい gen ファイルの大きさは列全体(値を持つすべての doc)で決まり、更新した doc が 1 件でも 3 件でも同じです。
PR の説明はこの状態を「Lucene rewrites the whole column for that field, even if you only touched a few docs」と書いています。
segment が大きくなるほど、1 回の更新で書く量も増えます。
soft delete の場合
この書き直しは明示的に doc-values を更新したときだけの話ではありません。
Lucene の soft delete は、削除の印を soft-deletes 用の numeric doc-values field に書くことで実現しています。
つまり soft delete も doc-values update であり、同じ経路で列を書き直します。
soft-deletes の列は印を付けた doc だけを持つので、1 回に書く量は数値 field の列ほど大きくありません。
Elasticsearch は操作の履歴を soft delete した doc として Lucene に残しています。
shard の復旧や CCR(cross-cluster replication)で、この履歴を使うためです。
後で見る Elasticsearch の PR #157749 の説明にも、この仕組みが出てきます。
OpenSearch は、この仕組みを持つ Elasticsearch 7.10 から分岐したプロジェクトです。
そのため、doc-values を明示的に更新していない index でも、update や delete で doc を置き換えるたびにこの経路を通ります。
10.6.0 で入った overlay
読み取り時の重ね方
PR の説明によると、10.6.0 で入った仕組みの考え方は単純です。
値を設定するだけの更新(set-only)なら、変わった doc だけを持つ疎な(sparse)差分を新しい gen として書きます。
PR ではこの差分を delta、読み取り時に重ねる層を overlay と呼んでいます。
先ほどの図と同じ更新でも、書くのは変わった doc の数だけになります。
読み取り時は base の列の上に delta を新しい順に重ね、同じ doc が複数の層にあれば最新の値を使う、という読み方です。
PR の説明はこれで「Updating a field becomes O(changed docs) instead of O(column)」になると書いています。
読み取り側は新しく入った OverlayNumericDocValues と OverlayBinaryDocValues(10.6.0 では OverlaySortedNumericDocValues も)が担当します。
delta は codec に元からある疎な形式で書くので、PR は「No doc-values format change」と書いています。
overlay が溜まったときの fold
delta を書き足すだけだと、層がどんどん積み上がり、読み取りが遅くなります。
そこで field ごと、segment ごとに、次の決まりで delta を畳みます(fold)。
flush のたびに、変わった doc だけの delta を 1 つ書く
delta の数が上限に達していたら、次の更新を書くときに既存の delta と今回の更新を 1 つにまとめる
fold のとき、既存の delta が持つ doc 数の合計が segment の doc 数の 50% 以上なら、base と合わせて新しい密な(dense)base を書く
segment の merge で、普通の列に戻る
上限の数は後で見る setMaxDocValuesOverlays で指定します。
最初の実装では、dense な base に戻すのは delta が列全体を覆ったときだけでした。
review で neoremind が bug を見つけます。
一度 dense な base に fold すると、それ以降の fold がすべて列全体の書き直しに戻っていました。
作者は判定を segment に対する割合に変え、閾値を 50% にして直しています。
overlay にならない更新
すべての更新が delta になるわけではありません。
PR #16418 の時点の対象は NUMERIC と BINARY の doc-values でした。
10.6.0 では #16581 で SORTED_NUMERIC も単一の値で更新できるようになりました。
この更新は、列が単一値か複数値かに関係なく同じ delta の経路を通ります(複数値の doc は読み取り時に doc 単位で保たれます)。2
OpenSearch や Elasticsearch の数値 field は SORTED_NUMERIC の列として書かれるので、この拡張も関係します。
どの型でも対象は値を設定するだけの更新に限られ、値を消す(unset する)更新が混ざると、従来どおり列全体を書き直します。
ベンチマークで見る効果と代償
更新の間隔による書き込み量の違い
PR #16418 には、作者自身のベンチマークが載っています。
対象は 8 バイトの数値と 512 次元の vector を持つ 5M(500 万)doc です。
4 スレッドがランダムな順序で更新し、NRT reader を 1 秒ごとに refresh します。
比べている方法は 3 つです。
full reindex は updateDocument で doc ごと入れ直す方法、dense は 10.5 までの doc-values update、sparse は今回の overlay です。
作者は、書き込み量が更新の間隔で大きく変わると考え、全速で更新する batch と、毎秒 5,000 件に絞った throttled の 2 通りで測っています。
数値 field の結果です。3
条件
方法
bytes/update
書き込みの倍率
batch
full reindex
13,138
1,642×
batch
dense
80
10×
batch
sparse
8
1.0×
throttled 5k/s
full reindex
14,359
1,795×
throttled 5k/s
dense
7,202
900×
throttled 5k/s
sparse
18
2.2×
倍率は書いたバイト数を実際に変わった 8 バイトで割った値です。
batch では dense でも 10× で済んでいます。
refresh 1 回のあいだに大量の doc が更新されるので、列全体の書き直し 1 回を多くの doc で分け合えるからです。
throttled にすると、同じ書き直しが数千件ぶんしかカバーしなくなり、900× に跳ね上がります。
sparse は間隔に関係なく変わった doc だけを書くので、どちらでも 1.0〜2.2× に収まります。
throttled の dense と sparse を比べると、書き込み量は約 400 分の 1 です。
throttled の行では、dense と full reindex の差も小さくなっています。
doc ごと入れ直す 14,359 バイトに対して、doc-values update は 7,202 バイトで、半分にしかなっていません。
32 バイトの binary field では、さらに逆転します。
条件
方法
bytes/update
throttled 5k/s
full reindex
15,756
throttled 5k/s
dense
27,734
throttled 5k/s
sparse
55
このベンチマークの binary field では、更新が少しずつ流れ続けると、10.5 までの doc-values update が doc ごと入れ直すより多く書いていたことになります。
作者は throttled を「closer to a steady trickle on a live index」と位置づけていて、運用中の index に近いのはこちらの条件です。
soft delete でも同じ傾向が出ていて、throttled では dense の 33 bytes/update が sparse で 6 bytes/update になっています。
読み取りの遅れ
書き込みが減るぶん、読み取りは遅くなります。
読み取りのたびに base と delta を重ね合わせるからです。
同じベンチマークで、500 万 doc の field を頭から読む scan の時間はこう変わりました。
field
条件
dense
sparse
数値
batch
35 ms
118 ms
数値
throttled 5k/s
21 ms
103 ms
binary 32B
batch
29 ms
327 ms
binary 32B
throttled 5k/s
28 ms
172 ms
scan は 3〜11 倍遅くなっています。
作者はソートやファセットのように doc を飛び飛びに引く読み方も測っています。
advanceExact で 10% の doc を引く測定では、overlay が 16 層のとき約 18 ms で、通常の列は約 1 ms でした。4
throttled で約 350 の gen が残った状態では、約 26 ms 対約 7 ms です。
作者はこれを「tens of ns per lookup」と書き、クエリ全体の時間と比べれば小さいとしています。
ただし、どちらの数値も作者が PR の review のために書いたベンチマークのものです。
benchmark のコードは merge 前に外すと書かれていましたが、2026 年 11 月 2 日時点では main のテストソースに、単独で実行する IncrementalDocValuesUpdatesBenchmark として残っています。
luceneutil や nightly benchmark のような第三者の測定は見つかりませんでした。
自分の index で書き込みの減少が読み取りの遅れに見合うかは、更新の間隔と読み取りの重さを比べて決めることになります。
overlay を有効にする設定
overlay は IndexWriterConfig#setMaxDocValuesOverlays(int) で有効にします。
0 は無効で、従来どおり列全体を書き直す設定です。
1 以上の値を渡すと有効になり、その数まで overlay を溜めてから fold します。
10.6.0 の Javadoc はこの値のトレードオフを次のように書いています。5
higher values lower write amplification but keep more overlays (hence more files) live to merge at read time.
10.6.0 の default は 0 で、この機能は opt-in です。
setMaxDocValuesOverlays の Javadoc には「Only takes effect when IndexWriter is first created」ともあり、6動いている IndexWriter に後から設定しても効きません。
!
10.6.0 のリリースノートの Highlights には「This is on by default」と書かれていますが、10.6.0 のソースの default は 0 です。
backport の PR #16554 も「opt-in on 10.x」と明記しています。
on by default なのは 11.0 系(branch_11x)と main(12.0)で、default は #16648 で 16 から 8 に下がっています。
アップグレード後に古い Lucene で開けるか
overlay を有効にしなくても、10.6.0 で変わることが 1 つあります。
segments ファイルの format version が上がりました。7
/** The version that records per-field incremental doc-values overlay generations. */
public static final int VERSION_10_6 = 11;
static final int VERSION_CURRENT = VERSION_10_6;
古い Lucene は overlay を読めないので、delta を列全体と取り違えないよう、format version で弾く必要があります。
PR #16554 はこの version を機能を使うかどうかに関係なくすべての commit で書くと説明しています。
So any index written by 10.6+ is rejected by older 10.x readers with IndexFormatTooNewException, whether or not it uses the feature.
Lucene は新しいバージョンで書いた index を古いバージョンで読めることを元から約束していません。
そのため規約の上では何も失われていない、というのが PR の立場です。
運用側から見ると、10.6.0 で一度でも commit した index は、10.5 系の Lucene に戻した時点で開けなくなります。
ローリングアップグレードの途中で旧版に戻す手順を用意しているなら、10.6.0 からは使えないと考えたほうが安全です。
review では ChrisHegarty が、commit 時に dense へ fold すれば古いバージョンでも読めるのでは、と提案しています。
作者は、refresh と commit を同じ頻度で行う使い方ではcommit 時に fold する案ではこの機能の効果が消えるとして、採用しませんでした。
同じリリースの .dvp 分離
10.6.0 では doc-values のファイル構成も別の変更で変わっています。
PR #16540(issue #16298)は、疎な field の doc ID 集合(IndexedDISI)を .dvd から新しい .dvp ファイルに分けました。
overlay とは独立した変更ですが、どちらも「doc-values まわりのファイルが変わった」と読めるので、混同しないよう気をつけてください。
Lucene90DocValuesConsumer や Lucene90DocValuesProducer のコンストラクタを直接呼んでいるコードは、こちらの変更に合わせて直す必要があります。
OpenSearch の star-tree がその例です。
OpenSearch と Elasticsearch の対応状況
OpenSearch
OpenSearch は PR #23475 で Lucene を 10.5.2 から 10.6.0 に上げ、2026 年 10 月 31 日に main へ merge しました。
Version.V_3_10_0 が LUCENE_10_6_0 を指すようになっています。
PR で直したコードの 1 つは、前の節で触れた .dvp 分離に合わせた star-tree の doc-values の対応です。
OpenSearch は overlay の設定には手を入れていません。
PR の diff にも、GitHub のコード検索で見た OpenSearch のリポジトリにも setMaxDocValuesOverlays は出てきません。
Lucene 10.6.0 は default の 0、つまり overlay が無効のまま取り込まれています。
一方、segments format の変化は、機能の有無に関係なく OpenSearch にも及びます。
PR には書かれていませんが、Lucene 側の仕様から考えると、OpenSearch 3.10 で書いた index は Lucene 10.5 系の OpenSearch では開けなくなるはずです。
Elasticsearch
Elasticsearch には、10.6.0 への upgrade PR は見つかりませんでした。
Elasticsearch は lucene_snapshot というブランチで Lucene の開発版に追従しているので、公開リポジトリ上の取り込み方が OpenSearch とは違います。
代わりに、overlay を使う機能の提案があります。
#16418 の作者である jimczi による PR #157749「Add updatable doc-values fields」で、2026 年 11 月 1 日時点で open のままです。
feature flag doc_values_updatable の内側にあり、ラベルは v9.7.0 です。
この PR は mapping に doc_values: { updatable: true } を足し、_update や _bulk の部分更新を doc の入れ直しではなく doc-values の書き換えで行います。
対象は keyword、数値、date の field で、index: false であること、index sort に含まれないこと、sub-field を持たないことが条件です。
PR が「columnar indices only」と書く種類の index で、新しい index version(IndexVersions.DOC_VALUES_UPDATABLE)以降に作ったものに限られます。
PR はねらいを「avoiding the cost of rewriting large immutable fields such as vectors」と書いていて、はじめにで挙げた「vector を持つ doc の数値 1 つだけを変える」場面そのものです。
overlay の上限は index 設定 index.doc_values.max_overlays で決めます。
doc-values の書き換えだけで済ませるには、Elasticsearch 側にも工夫が要ります。
_seq_no は in-place で書き換えられないので、更新の sequence number を _dv_update_seq_no という別の列に記録しています。
また、更新の差分だけを replica に送っても、replica が base の doc を持っていなければ意味がありません。
そのため、base の doc が global checkpoint 以下(すべての in-sync なコピーが持っていることが保証される範囲)にあるときだけ差分を送り、それ以外は従来の reindex に戻しています。
関連する変更と残っている課題
#16418 の後にも、いくつかの変更と議論が続いています。
ファイル数の問題は issue #16644 で dweiss が報告しました。
テストがファイルハンドルの上限に引っかかったというもので、issue に引用された分析は、開いているファイルの数が「segments × 更新した field 数 × (1 + overlay 数) × 2」に比例して増えるという見積もりを書いています(原文は英語)。
作者の jimczi は、増え方は field ごとに overlay の上限で抑えられると答えています。
issue はテスト側で overlay 数をランダムにする #16645 で閉じられ、これとは別に #16648 で default も 16 から 8 に下がりました。
overlay を有効にするなら、OS のファイルハンドル上限も合わせて確認しておくと安全です。
merge 中の更新の扱いも変わりました。
10.5 までは、merge 中に来た doc-values update を ReadersAndUpdates が heap に溜めていました。
#16570 はこれをやめ、merge の完了時に元の segment から読み直すようにしています。
10.x への backport(#16591)では、この挙動は overlay を有効にしたときだけ働きます。
10.6.0 には、SORTED_NUMERIC の更新のほかにも関連する変更が入っています。
#16565 は index sort に使う field への binary update を拒否するようにしました。
PR #16418 の review で後回しにされた項目と、10.6.0 のソースに TODO として残っている項目もあります。
reader を開くときに delta を 1 つにまとめる。shaie の提案に、作者は「it's the top follow-up」と答えています8
読み取りで intoBitSet を使う、dense な base を直接読んで変わった doc だけ差し替える9
field ごとに設定を変えられるようにする。ChrisHegarty の提案で、作者は「Good follow-up」と答えています10
毎回すべての delta を畳み直すのをやめ、size-tiered な fold にする11
fold の閾値 50% を設定で変えられるようにする12
確認できていないこと
dev メーリングリスト13に、この機能を扱った専用のスレッドは見つかりませんでした。確認できたのはリリースの告知だけです。
ベンチマークの数値は作者のものだけで、第三者の測定は見つかっていません。
overlay を有効にしたときに、実際の index でファイルハンドルがどれだけ増えるかは測っていません。
Elasticsearch が Lucene 10.6.0 をどのバージョンで取り込むかは確認できていません。
おわりに
はじめにの在庫数の例に戻ります。
10.5 までの updateNumericDocValue は在庫数を数件変えただけでも、refresh のたびに segment ぶんの在庫数の列を書き直していました。
10.6.0 で overlay を有効にすれば、書くのは変わった在庫数だけになります。
その代わり、在庫数でソートや集計をするたびに、base に delta を重ねる手間がかかります。
10.6.0 では opt-in なので、何もしなければこの挙動は変わりません。
ただし segments format は必ず上がるので、10.6.0 で一度 commit した index は 10.5 では開けなくなります。
Lucene を直接使っているなら、まず自分の index の更新の間隔と、ソートやファセットの重さを見比べてみてください。
更新が少しずつ続き、読み取りが軽いなら、setMaxDocValuesOverlays に小さな値を渡して試す価値があります。
参考リンク
Release 10.6.0 · apache/lucene
Lucene Core News
CHANGES.txt(10.6.0)
Incremental doc-values updates · PR #16418
Backport incremental doc-values updates to 10.x · PR #16554
Incremental doc values increase open file handle count significantly · Issue #16644
Upgrade Lucene to 10.6.0 · OpenSearch PR #23475
Add updatable doc-values fields · Elasticsearch PR #157749
脚注
ReadersAndUpdates.java#L323-L324(10.5.0) ↩︎
OverlaySortedNumericDocValues.java(10.6.0) の Javadoc。CHANGES.txt の GITHUB#16580 は「Single-valued columns reuse the numeric update path」と単一値の列に限った書き方をしていますが、コードは複数値の列にも delta を書きます。 ↩︎
PR #16418 の Numbers 節。この節の表と数値は、特記しない限りここから引いています。 ↩︎
PR #16418 の作者のコメント。「tens of ns per lookup」もこのコメントからの引用です。 ↩︎
IndexWriterConfig.java#L111-L116(10.6.0) ↩︎
IndexWriterConfig.java#L336-L348(10.6.0) ↩︎
SegmentInfos.java#L129-L132(10.6.0) ↩︎
作者の回答 ↩︎
作者のコメント ↩︎
ChrisHegarty の提案、作者の回答 ↩︎
ReadersAndUpdates.java#L390-L391(10.6.0) の TODO ↩︎
ReadersAndUpdates.java#L58-L60(10.6.0) の TODO ↩︎
Lucene の開発者向けメーリングリスト dev@lucene.apache.org です。Apache Software Foundation のプロジェクトでは、リリースの投票や設計の議論をこのリストで行います。アーカイブは lists.apache.org で検索できます。確認できた告知は 10.6.0 の ANNOUNCE メール です。 ↩︎
VictoriaLogs 与 Loki、Elasticsearch、OpenSearch、SigNoz、ClickStack 的日志写入性能对比
> This blog post is also available in English: [VictoriaLogs vs Loki, Elasticsearch, OpenSearch, SigNoz and ClickStack on structured log ingestion]( victoriametrics.com)
在 VictoriaMetrics 内部,我们会持续地对自己的产品进行性能测试。这能帮助我们保持竞争力,让每一个版本都变得更好。随着越来越多的团队开始使用 VictoriaLogs ,也有越来越多的人问我们:它适合哪些场景?切换过来能获得什么?因此,我们把基准测试作为一个[公开 GitHub 仓库]( VictoriaMetrics/logs-benchmark)发布出来,任何人都可以运行它来复现我们的结果,我们也会继续以开源的方式维护它。
这不是一篇劝你在迁移到 VictoriaLogs 的广告。相反,如果你在采集结构化日志(无论来自 Kubernetes 还是其他地方),本文应该能帮你选择合适的工具,并了解适合你的权衡取舍。
对不同系统进行比较的基准测试总是充满争议,因为每个系统擅长的场景各不相同。本文只测试一个场景:写入结构化日志,即每一行都带有命名字段的日志,Kubernetes 上的许多服务都是这样输出日志的。每个系统都运行在一台相同规格的机器上。查询性能、集群模式和对象存储会在后续文章中介绍。
仅凭写入速度无法决定该选哪个系统,所以本文也会深入它们的内部实现:
- 为什么 Loki 写入速度最快,但一个简单的 Dashboard 查询却要花上几分钟?
- 为什么 SigNoz 和 ClickStack 落后于 VictoriaLogs ,尽管三者都以列式方式存储日志?
- 为什么 Elasticsearch 和 OpenSearch 需要那么多磁盘空间?
这些问题的答案能帮助你规划硬件和成本,避免在日志量增长时遇到意外。
测试与环境
我们测试了截至 2026 年 10 月 6 个系统的最新版本,配置尽量接近默认值:
- VictoriaLogs [v1.53.0]( github.com/VictoriaMetrics/VictoriaLogs/releases/…)
- Loki [3.7.8]( github.com/grafana/loki/releases/tag/v3.7.8)
- Elasticsearch [9.5.5]( github.com/elastic/elasticsearch/releases/tag/v9.…)
- OpenSearch [3.9.0]( github.com/opensearch-project/OpenSearch/releases…)
- SigNoz [v0.145.0]( github.com/SigNoz/signoz/releases/tag/v0.145.0)
- ClickStack [2.39.1]( github.com/hyperdxio/hyperdx/releases/tag/%40hype…)
我们调整了少量配置,例如 Loki 的写入限制,以便每个系统都能在单节点上达到其设计上限。VictoriaLogs 不需要任何调整,因为它的设计目标就是开箱即用地提供最佳性能([文档]( docs.victoriametrics.com))。[仓库]( github.com/VictoriaMetrics/logs-benchmark/tree/ma…)中说明了每一项改动。
为了让测试保持简单,每个系统都以单节点模式运行在独立的 Kubernetes 节点上( GCP 的 n2d-standard-8 ,8 个 vCPU 、32 GB 内存)。SigNoz 和 ClickStack 将日志存储在 ClickHouse 中,而 ClickHouse 本身无法直接接收日志采集器发送的日志。因此每个技术栈都会在 ClickHouse 所在的节点上运行自己的 Collector ,这些 Collector 基于 OpenTelemetry Collector 构建。

我们同时向每个系统发送相同的日志。写入速率从 10k 行/秒开始,每 10 分钟增加 10k ,最高到 180k 行/秒。一个系统的最大写入速率,是指它在一个完整的 10 分钟阶梯内平均能成功存储的最高速率。如果系统发生崩溃,我们只统计它首次崩溃之前的阶梯,因为崩溃本身就是它的极限。

我们统计的是每个系统实际存储的行数,而不是它接收的行数。例如,SigNoz 和 ClickStack 在 Collector 收到日志后就会立即返回响应,因此 Collector 一旦崩溃,就会丢失已经被接收的日志。Loki 是个例外:它没有低成本的方法统计已存储的行数,但它会在返回响应之前把每一行写入 WAL ( Write-Ahead Log ,预写日志),所以对于 Loki ,我们统计的是接收的行数。
写入速率只是故事的一半。为了展示它与查询速度之间的关系,我们以 35k 行/秒的速率向 VictoriaLogs 、Elasticsearch 和 Loki 写入相同的日志,持续 4 小时,然后在冷缓存状态下对几条常见查询进行计时。我们会在后续文章中深入讨论查询。
每个系统接收的负载
日志可以是任何东西,从纯文本到包含数百个字段的 Wide Events (宽事件)。本测试选取了中间情况:每行包含 30 到 40 个字段的结构化日志,这在 Kubernetes 中很典型。其中约 20 到 30 个字段由应用写入,日志采集器再额外添加约 10 个字段,例如命名空间、节点和标签。
设想一个有 125 个节点的 Kubernetes 集群,每个节点运行 40 个属于不同应用的 Pod 。每个 Pod 的日志构成一个日志流,因此这个集群共有 5,000 个日志流。这是一个中等规模的集群,与我们在 VictoriaLogs 用户中见到的许多集群类似。

我们想看看每个系统基于自身设计能走多远。因此,每个系统都以它偏好的格式接收日志。例如,Loki 通常从 Grafana Alloy 接收 protobuf 格式。我们没有向所有系统发送同一种格式(如 OTLP )。如果那样做,我们测量的就不仅是各系统存储日志的能力,还包括它们转换该格式的效率。
在实际场景中,日志通常以 JSON 形式输出到 stdout 。因此日志采集器需要对其进行转换,而这同样会消耗 CPU 。如果有足够多的读者感兴趣,我们也可以用一种通用格式(如 JSON 或 OTLP )重新运行测试。
测试结果
主写入测试我们运行了 3 次,下面的结果取 3 次运行的中位数。完整的数据表格见[仓库]( github.com/VictoriaMetrics/logs-benchmark/tree/ma…)。
对于 SigNoz 和 ClickStack ,CPU 和内存包含了 Collector 、ClickHouse 及其辅助服务。
本文中提到的少数额外测试(例如查询测试)是仓库之外的一次性运行。
最大写入速率(行/秒)

- Loki 写入最多,达到 139k 行/秒。
- VictoriaLogs 位居第二,为 120k 。
- Elasticsearch 、OpenSearch 和 ClickStack 在 28k 到 48k 之间。
- SigNoz 达到 10k 行/秒,并在 20k 时崩溃。
一行日志约为 930 字节的 JSON ,因此 120k 行/秒约等于 106 MiB/s ,即每天约 9 TiB 的原始日志。
每个数字的背后都伴随着在查询速度、成本或日志类型上的权衡。我们会在[为什么会是这样的结果](#为什么会是这样的结果)中进行解释。
CPU 使用量(核)
各写入速率下的平均 CPU 核数。每条曲线终止于该系统的最大写入速率。

- 在 10k 行/秒(所有系统都能承受的唯一速率)下,VictoriaLogs 和 Loki 使用 0.4 核,ClickStack 0.9 核,Elasticsearch 1.0 核,OpenSearch 1.5 核,SigNoz 4.2 核。
- 差距随速率增长而扩大。在 40k 行/秒时,VictoriaLogs 和 Loki 使用 1.6 核,ClickStack 4.0 核,Elasticsearch 5.2 核。
- 在 120k 行/秒时,Loki 使用 6.1 核,VictoriaLogs 使用 7.2 核。
在本次测试中,CPU 是大多数系统的瓶颈。
内存使用量( GiB )
各写入速率下的峰值内存,不含操作系统的 Page Cache 。每条曲线终止于该系统能跟上的最后一个速率。

- VictoriaLogs 在所有速率下内存占用都最少,在 40k 行/秒时比 Loki 少 14 倍。
- Elasticsearch 和 OpenSearch 无论速率如何都使用约 17 GiB ,因为它们在启动时会预留固定大小的堆内存。默认情况下,Elasticsearch 会将堆大小设为机器内存的一半([源码]( github.com/elastic/elasticsearch/blob/v9.5.5/dist…))。OpenSearch 不会自动设置堆大小,所以我们给它配置了相同的大小。
- ClickStack 在 10k 行/秒时使用 1.3 GiB ,在 40k 行/秒时使用 2.2 GiB 。
- SigNoz 在 10k 行/秒时使用 3.1 GiB ,这是它在图表中唯一的数据点。
内存决定了机器需要多大、成本多高,以及还剩多少内存可用于缓存和查询。
磁盘空间
下图展示了每个系统存储一百万行日志所需的磁盘空间。作为参考,一百万行日志以原始 JSON 形式约占 887 MiB 。SigNoz 标为 N/A ,因为它的 Collector 反复崩溃,存储的行数太少,且多次运行之间的数据波动太大,无法进行比较。

- VictoriaLogs 所需磁盘空间最少,其次是 ClickStack 和 Loki。
- Elasticsearch 所需空间几乎是 VictoriaLogs 的 5 倍,OpenSearch 则是 10 倍。
日志通常会保留数周,因此磁盘空间增长很快。例如,以 50k 行/秒的速率保存 30 天的日志,VictoriaLogs 约需 7 TiB ,Loki 约需 12 TiB ,Elasticsearch 约需 33 TiB 。
磁盘写入量
下表展示了每个系统每存储一行日志向磁盘写入的字节数。这个数值会大于最终的磁盘占用,因为每个系统都会不时重写数据,以保持数据紧凑并便于搜索。N/A 表示该系统无法跟上对应的速率。
| 系统 | 10k | 20k | 40k | 80k | 120k | 150k |
| --- | --- | --- | --- | --- | --- | --- |
| Loki (protobuf) | 1,108 | 1,234 | 1,411 | 1,251 | 1,210 | N/A |
| VictoriaLogs | 323 | 284 | 226 | 223 | 163 | N/A |
| Elasticsearch | 1,393 | 1,815 | 1,917 | N/A | N/A | N/A |
| OpenSearch | 2,295 | 3,197 | N/A | N/A | N/A | N/A |
| SigNoz | 1,420 | N/A | N/A | N/A | N/A | N/A |
| ClickStack | 414 | 461 | 572 | N/A | N/A | N/A |
VictoriaLogs 每行写入磁盘的数据量同样最少,而且在更高速率下写得更少,因为一次写入更多行时压缩效果更好。Loki 每行的写入量是它的 3 到 7 倍,具体取决于速率。
这一点很重要,因为每块磁盘每秒能写入的字节数都有上限,而更快的磁盘也更贵。在我们的测试中,使用的是 GCP 上 500 GiB 的 pd-balanced 磁盘,最高写入速度为 280 MiB/s ,所以没有任何系统在耗尽 CPU 之前触及这个上限。尽管如此,VictoriaLogs 能让你使用更便宜的磁盘;在自有硬件上,它对 SSD 的磨损也要慢得多。
为什么会是这样的结果
Loki
Loki 的高写入速率让我们感到意外,但它的设计可以解释这一点。Loki 的设计目标是存储大量日志,之后只在较短的时间范围内读取少数几个日志流。因此它只为流标签(这里是节点和应用)建立索引,并原样存储每一行日志。相比之下,VictoriaLogs 会把每一行拆分为字段,并分别存储每个字段,从而让后续的查询更快。在 120k 行/秒时,这会比 Loki 多消耗约 18% 的 CPU 。
向 Loki 发送日志的方式也很重要。Grafana Alloy 和 Vector 默认发送 protobuf ,而 Fluent Bit 、Fluentd 和 Logstash 发送 JSON 。在另一次单独的测试中,使用 JSON 时 Loki 只达到约 99k 行/秒,低于 VictoriaLogs ,因为处理 JSON 比处理 protobuf 消耗的 CPU 多得多。所以如果你使用 Loki ,建议优先选择 Alloy 或 Vector 。
Loki 的设计有 3 个代价。第一个代价是内存。Loki 会把每个流最新的日志行保存在内存中,以便将它们一起压缩。这意味着它的内存会随着流的数量增长。集群越大,流就越多;任何集群在部署期间流也会增多,因为每个新 Pod 都会产生一个新的流。为了观察这会占用多少内存,我们还分别用 10,000 和 15,000 个流运行了测试。

在 15,000 个流时,Loki 在 60k 行/秒时因 OOM 被杀死。而 VictoriaLogs 在流数量增加到 3 倍的情况下,仅多用了 0.2 GiB 内存。更多的内存意味着更大的机器,也意味着可用于缓存查询数据的内存更少。
第二个代价是磁盘。Loki 把日志行当作纯文本进行压缩。它不像 VictoriaLogs 和 ClickHouse 那样利用行内的字段来获得更好的压缩效果,因此它所需的磁盘空间是 VictoriaLogs 的 1.8 倍。
第三个也是最重要的代价是查询时间。Loki 只能通过标签来缩小查询范围。除此之外的任何条件,比如一个单词或一个字段值,都需要对匹配流中的每一行进行解压和扫描。在相同的日志上,前文提到的查询耗时如下。
| 查询问题 | 时间范围 | VictoriaLogs (秒) | Loki (秒) |
| --- | --- | --- | --- |
| 统计某个应用的错误数 | 1 小时 | 0.06 | 3.5 |
| 统计每个应用的错误数 | 1 小时 | 0.59 | 171 |
| 包含某个常见词的最新 100 行 | 1 小时 | 2.5 | 32 |
| 每个应用的 p99 延迟 | 1 小时 | 1.9 | 302 |
| 前 10 个请求路径 | 1 小时 | 8.8 | 被拒绝,超过 500 个 Series |
这些都是排查故障、构建 Dashboard 和告警时的日常问题。除第一条外,Loki 在所有查询上都要花费半分钟甚至更久,或者直接拒绝执行。第一条查询只通过标签读取一个应用的数据,即便如此,它也比 VictoriaLogs 慢了近 60 倍。
Loki 正在增加更快搜索字段的方法,例如 Bloom Filter (布隆过滤器),但这些功能仍处于实验阶段,默认关闭,并且在单节点上不可用,所以我们没有启用它们。
这些缓慢的查询并不意味着 Loki 总体上是一个糟糕的日志方案。它的设计目标是把日志低成本地保存在对象存储中,并在需要执行大型查询时横向扩展到许多查询工作节点上。在我们的单台机器上,统计一小时内每个应用的错误数花了 171 秒,大约使用了 8 核中的一半。粗略估算,若要在约 2 秒内得到结果,同样的扫描大约需要 300 个核。所以,如果你想把大量日志低成本地存储在对象存储中,并且要么只读取其中一小部分,要么愿意为大量 CPU 核付费以实现快速搜索,那么 Loki 适合你。
如果这超出了你愿意承担的成本,而你仍然需要 Dashboard 和搜索在几秒内返回结果,请继续往下读。
Elasticsearch 和 OpenSearch
OpenSearch 是 Elasticsearch 的一个 Fork ,所以本节主要介绍 Elasticsearch ,最后再说明 OpenSearch 的不同之处。
Loki 几乎不建索引,并在查询时为此付出代价。Elasticsearch 则恰恰相反。它以 3 种形式存储每一行日志:
- 该行的原始 JSON。
- 倒排索引,一个将每个单词和值映射到包含它们的日志行的字典,因此搜索可以直接定位到匹配的行。
- 再以列的形式存储一遍字段( doc values ),用于排序和聚合。

这一切都让写入变得昂贵。在 40k 行/秒时,Elasticsearch 使用了 5.2 核,而 VictoriaLogs 和 Loki 只用了 1.6 核;它在约 48k 行/秒时耗尽了 CPU 。瓶颈在于 CPU ,而不是磁盘。Elasticsearch 会在返回响应前把每个请求写入磁盘,但我们在另一次单独测试中关闭了这一行为,写入速率几乎没有变化。
我们的测试使用了 [LogsDB]( elastic.co),这是 Elasticsearch 针对日志推出的更新、更紧凑的索引模式。但即便如此,Elasticsearch 使用的磁盘空间仍是 Loki 的 2.6 倍,几乎是 VictoriaLogs 的 5 倍。
作为回报,Elasticsearch 查找数据的速度比 Loki 快得多。在 Loki 需要几分钟的查询上,Elasticsearch 只需几秒甚至更短。下面是它与 VictoriaLogs 在几类常见查询上的对比。
| 查询问题 | 时间范围 | VictoriaLogs (秒) | Elasticsearch (秒) |
| --- | --- | --- | --- |
| 统计包含某个常见词的行数 | 1 小时 | 6.5 | 0.10 |
| 统计每个应用的错误数 | 1 小时 | 0.59 | 0.46 |
| 每个应用的 p99 延迟 | 1 小时 | 1.9 | 3.9 |
| 前 10 个请求路径 | 1 小时 | 8.8 | 14.7 |
| 统计包含某个双词短语的行数 | 1 小时 | 4.4 | 175 |
Elasticsearch 查找单个词或值几乎是瞬间完成的,在简单计数(例如统计每个应用的错误数)上也更快。而一个短语,比如 "connection timeout",则花了它大约 3 分钟。
为了节省磁盘,它针对日志的默认配置不会存储每个单词在行中的位置,因此它必须重新读取日志行,才能判断两个词是否相邻。VictoriaLogs 在这类查询上更快,在百分位数和 Top 值查询上也更快。
Elasticsearch 之所以流行,是因为它能快速搜索任意字段。人们对它的抱怨通常集中在两点:
- 成本。 为每个字段建立索引会消耗大量 CPU 、内存和磁盘。
- 运维。 运维大型集群需要投入大量精力。
Uber 同时遇到了这两个问题。索引成本高昂,而且 Uber 在每个区域运行了 20 多个集群,以确保一个集群故障不会影响全局([Uber]( uber.com))。Cloudflare 则不得不对部分日志进行采样,因为全部存储需要数百 TB 的空间([Cloudflare]( blog.cloudflare.com))。两家公司都把部分最大规模的日志负载迁移到了 ClickHouse 。
OpenSearch 的写入量略高于 Elasticsearch 的一半( 28k 对 48k 行/秒),磁盘占用约为后者的两倍。它与 Elasticsearch 在两项默认设置上有所不同:
- 它没有类似 LogsDB 的模式,因此对每行原始 JSON 的压缩没那么紧凑([OpenSearch 文档]( docs.opensearch.org)),也不会预先排序。
- 它没有内置的日志模板,因此大多数字符串字段会被索引两次:一次按分词,一次按精确值。
OpenSearch 与 Elasticsearch 共享相同的内核,因此通过更强的压缩、排序以及完整的日志模板,你很可能可以弥补大部分差距。我们刻意让它保持接近默认配置,以展示开箱即用时的表现。
Loki 以牺牲查询速度为代价换取快速写入。Elasticsearch 以牺牲写入速度和存储为代价换取快速搜索。那么 VictoriaLogs 又提供了什么呢?
VictoriaLogs
如今,许多公司都在输出结构化日志,每一行都带有命名字段,例如日志级别、HTTP 状态码或请求耗时。VictoriaLogs 利用了这类日志的两个特性,使写入和查询都能保持高速。
1. 同一个流中的日志看起来很相似。它们的字段几乎相同,而且许多字段几乎不变。每一行的命名空间都相同,日志级别也只有少数几个取值。VictoriaLogs 会把每个流的日志存放在一起,并把每个字段存储在独立的列中,这比存储整行日志的压缩效果好得多。
2. 许多查询只需要少数几个字段。例如,统计每个应用的错误数只需要应用和级别两个字段。VictoriaLogs 只读取查询所需的列。

这种存储方式本身就让 VictoriaLogs 在磁盘上非常紧凑,聚合也很快,所以它在写入时只需构建轻量的索引和少量元数据。这就是为什么它能以 120k 行/秒的速率写入,是 Elasticsearch 的 2.5 倍,同时磁盘占用少 4.7 倍。正如前文所述,只有 Loki 的写入速度更快。更多细节请参阅 [VictoriaLogs 如何存储你的日志]( victoriametrics.com)。
在查询方面,VictoriaLogs 在几秒内响应了所有查询,并且在每一条查询上都比 Loki 快。与 Elasticsearch 相比则互有胜负,我们会在关于查询的文章中详细分析。为了保持低成本的写入和存储,VictoriaLogs 不会构建完整的倒排索引。其代价是,搜索一个常见词(出现在大量行中的词)会比较慢。而查找一个罕见词(例如用户 ID )依然很快,因为 Bloom Filter 可以让它跳过不可能包含该词的数据。
VictoriaLogs 使用的内存也少得多。在 40k 行/秒时,Loki 使用的内存约为它的 14 倍( 11.4 GiB 对 0.8 GiB );在 120k 行/秒时约为 5 倍( 12.8 GiB 对 2.5 GiB )。这些空闲内存并不会被浪费。操作系统会用它来缓存最近写入或读取的数据,因此许多查询(尤其是针对近期日志的查询)可以直接从内存而不是磁盘读取数据。它也为大量并发查询留出了空间,我们计划在后续关于查询的文章中对此进行测试。
公平地说,本次基准测试使用的是结构化日志,这正是 VictoriaLogs 的优势所在。而针对这类日志的日常工作,很大一部分也是用于 Dashboard 、告警和分析的聚合,这正是列式存储最能发挥作用的地方。如果你的日志是非结构化的,例如老旧应用和网络设备输出的纯文本日志,VictoriaLogs 依然能很好地写入和搜索它们,但收益会比结构化日志小一些。
SigNoz 和 ClickStack
SigNoz 和 ClickStack 比 Loki 更让我们意外。两者都把日志存储在 ClickHouse 中,而 ClickHouse 和 VictoriaLogs 一样是列式数据库,所以我们原本预期它们的表现会接近 VictoriaLogs 。但事实并非如此。ClickStack 达到 46k 行/秒,SigNoz 只有 10k ,而 VictoriaLogs 为 120k 。在磁盘空间方面,ClickStack 排名第二,但每百万行仍需要 82 MiB ,是 VictoriaLogs 的 1.5 倍。
较低的写入速率来自两个搭建在 ClickHouse 之上的方案,来自于它们的 Collector 。ClickHouse 是一个通用数据库,无法直接接收常见格式的日志,因此需要由一个 OpenTelemetry Collector 先把日志转换成行数据。这个 Collector 消耗的 CPU 比 ClickHouse 本身还多。在 SigNoz 中,10k 行/秒时 Collector 使用了 2.7 核,而 ClickHouse 为 1.5 核;在 ClickStack 中,达到最大速率时两者分别为 3.3 核和 2.2 核。

更大的磁盘占用可能源于两个技术栈存储字段的方式。它们把日志存储在一张固定列的 ClickHouse 表中,但 [OpenTelemetry 日志格式]( opentelemetry.io) 只有少数几个固定字段,外加可以容纳任意自定义数据的 Attributes 。固定字段(如 Timestamp 、SeverityText 和 TraceId )拥有独立的列,压缩效果很好。而 Attributes 则被放入少数几个 Map 列中,许多字段的值混杂在一起,因此压缩效果较差,查询也更慢。
VictoriaLogs 受到了 ClickHouse 的启发,但它是专门为日志而构建的([FAQ]( docs.victoriametrics.com))。它与 ClickHouse 有两点不同,这很可能解释了写入速率和磁盘空间上的大部分差距:
- 无需 Collector 。 VictoriaLogs 可以直接接收日志采集器已经在发送的各种格式,例如 OpenTelemetry 、Loki 、Elasticsearch 、Datadog 和 Splunk 。
- 无需 Schema 。 日志行中的每个字段都有自己独立的列,而不仅仅是少数几个固定字段。
在本次测试中,基于相同设计的 SigNoz 使用的 CPU 几乎是 ClickStack 的 5 倍,主要消耗在其 Collector 上。Collector 把这些 CPU 花在了额外的元数据上,这些元数据可以加速查询并为其 UI 提供支持,我们会在关于查询的文章中探讨这一权衡。此外,它默认不设内存上限,而我们保留了这一默认配置。因此当它处理不过来时,会把日志行一直堆积在内存中,直到被 Kubernetes OOM Kill 。其他用户也报告过类似的 Collector 崩溃问题([GitHub issue]( signoz#6128))。
公平地说,SigNoz 和 ClickStack 是完整的可观测性技术栈,它们把日志、指标和链路追踪汇集在一处,并把每条链路关联到对应的日志。本次测试只衡量了其中的一部分:日志写入。两个技术栈也都建议随着数据量增长增加更多的 Collector 。ClickHouse 的文档按约 3 核处理 60k 事件/秒来规划单个 Collector ([ClickHouse 文档]( clickhouse.com)),而 SigNoz 自己的基准测试在一台 16 核机器上,于 ClickHouse 旁运行了 3 个 Collector ,达到约 55k 行/秒([SigNoz]( signoz.io))。在我们的测试中,为了对所有系统公平,每个系统都只分配了一台 8 核机器,所以 Collector 不得不与 ClickHouse 共享资源。集群测试中各个组件可以独立扩展,能够展示它们从更大规模的部署中能获得多少收益,这也在我们的计划之中。
关键结论
每个系统都根据自身的优先级和架构做出了不同的权衡。
- Loki 写入最多,因为它建立的索引最少。代价是查询缓慢,除非你为更多 CPU 核付费来加速查询。
- Elasticsearch 和 OpenSearch 为每一行的每一个词建立索引。这让它们能够非常快速地查找单词和精确值。但代价是写入时 CPU 消耗高,并且需要大量存储空间。
- SigNoz 和 ClickStack 把日志存储在 ClickHouse 中。ClickHouse 并非专为日志而构建,因此两个技术栈都需要额外的组件,这会拖慢写入速度并增加成本。如果你希望把 OTLP 日志、指标和链路追踪集中在一处,它们会比较适合你。
- VictoriaLogs 受到 ClickHouse 的启发,但它是直接为日志而构建的。它充分利用现代结构化日志的特点,实现快速写入、秒级查询响应,并且在内存和磁盘上的成本最低。如果你的日志是纯文本,收益会小一些。
本文在单台机器、普通云盘上测试了结构化日志。在后续文章中,我们计划深入介绍查询、集群模式,以及在 VictoriaLogs [支持对象存储]( VictoriaLogs#1155)后的对象存储测试。
你可以使用我们的[基准测试仓库]( VictoriaMetrics/logs-benchmark)复现主要的写入测试结果,也可以通过[快速入门指南]( docs.victoriametrics.com)试用 VictoriaLogs 。如果你发现我们的测试环境有错误,或者知道如何更好地调优某个系统,欢迎在[仓库]( github.com/VictoriaMetrics/logs-benchmark/issues)中提交 issue ,或在 [VictoriaMetrics 的 Slack]( slack.victoriametrics.com) 上联系我们。
4 comments
OpenSearchのLLM-as-a-Judgeを日本語の商品検索で動かして、人手ラベルと比べてみた
はじめに
Search Relevance Workbench(SRW)は、OpenSearch 3.1で導入された検索品質を評価するための機能です。クエリと検索設定、正解データを組み合わせてNDCGなどの指標を計算できます。正解データは自分で用意するのが一般的ですが、SRWにはLLMに検索結果の関連度を採点させて正解データを作る、LLM-as-a-Judgeという機能も用意されています。
英語の紹介記事はありましたが、日本語のデータで試した例は見当たらなかったので、今回はAmazonの商品検索データセット(ESCI)の日本語データを使って実際に動かしてみました。ESCIには人が付けた関連度ラベルがあるので、いくつかの例でLLMの採点と見比べてみます。
使ったものは以下のとおりです。
OpenSearch / OpenSearch Dashboards 3.9.0(Docker)
analysis-kuromoji 3.9.0
Gemini 3.5 Flash-Lite(Gemini API)
Shopping Queries Dataset(ESCI)の日本語データ
Python 3.12(uv)
SRWとESCIについて
SRWは、以下の4つの部品を組み合わせて検索品質を評価します。
Query set:評価に使うクエリ
Search configuration:検索の設定(どのインデックスを、どんなクエリで検索するか)
Judgment list:正解データ(クエリ・文書・評価値の組)
Experiment:検索設定の結果を正解データで採点して、NDCGなどを計算する
正解データは、外部で作った評価値を取り込む(IMPORT_JUDGMENT)こともできますが、今回はLLMに検索結果を採点させるLLM_JUDGMENTを試します。採点は0〜1の段階評価(SCORE0_1)か、関連あり/なしの二択(RELEVANT_IRRELEVANT)を選べます。
ESCIはAmazonが公開している商品検索のデータセットで、クエリと候補商品の組に、人が以下の4段階のラベルを付けています。
E(Exact):条件をすべて満たす
S(Substitute):一部違うが代わりに使える
C(Complement):一緒に使うもの
I(Irrelevant):無関係
環境構築
Dockerを使ってOpenSearchとDashboardsを起動します。Dockerfileでは、OpenSearch公式のDockerイメージをベースに、日本語の形態素解析を行うkuromojiプラグインをインストールします。
docker/opensearch/Dockerfile
ARG OPENSEARCH_VERSION=3.9.0
FROM opensearchproject/opensearch:${OPENSEARCH_VERSION}
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-kuromoji
3.9.0のSRWプラグインにはslf4j-apiが入っておらず、LLM_JUDGMENTでLLMの応答を受け取ると
NoClassDefFoundError: org/slf4j/LoggerFactoryでOpenSearchが落ちる。ML Commonsのものをコピーして補う
RUN cp /usr/share/opensearch/plugins/opensearch-ml/slf4j-api-1.7.36.jar \
/usr/share/opensearch/plugins/opensearch-search-relevance/
1ノード構成でコンテナを定義します。今回は動作確認が目的なので、セキュリティ設定を無効化しています。
docker/docker-compose.yml
name: srw-esci-ja
services:
opensearch:
build:
context: ./opensearch
args:
OPENSEARCH_VERSION: 3.9.0
image: srw-esci-ja/opensearch-kuromoji:3.9.0
container_name: srw-opensearch
cpus: 4 # LLM_JUDGMENTが同時にLLMを呼ぶ数はCPUの数で決まるので、APIの回数制限に当たらないよう絞る
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
- DISABLE_INSTALL_DEMO_CONFIG=true # 動作確認用なのでdisableにする
- DISABLE_SECURITY_PLUGIN=true # 動作確認用なのでdisableにする
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-data:/usr/share/opensearch/data
ports:
- 9200:9200
dashboards:
image: opensearchproject/opensearch-dashboards:3.9.0
container_name: srw-dashboards
environment:
- OPENSEARCH_HOSTS=["opensearch"]
- DISABLE_SECURITY_DASHBOARDS_PLUGIN=true # 動作確認用なのでdisableにする
ports:
- 5601:5601
depends_on:
- opensearch
volumes:
opensearch-data:
コンテナを起動します。
$ docker compose -f docker/docker-compose.yml up -d --build
商品データを入れる
ESCIのデータは、amazon-science/esci-dataのリポジトリからダウンロードします。parquetファイルはGit LFSで管理されているので、github.com/.../raw/...のURLを指定します。
$ curl -L -O github.com/amazon-science/esci-data/raw/refs/head…
$ curl -L -O github.com/amazon-science/esci-data/raw/refs/head…
ESCIのラベルは、クエリごとの候補商品にしか付いていません。今回は日本語データから一部のクエリを選び、その候補商品だけをインデックスに入れます。
ここからのOpenSearchへのリクエストは、DashboardsのDev Tools(localhost)から実行します。
まず、以下のマッピングでproducts_kuromojiインデックスを作成します。テキストのフィールドにはkuromojiを使い、どのクエリの候補かを表すcandidate_queriesは、完全一致で検索するためにkeyword型にしています。
PUT products_kuromoji
{
"mappings": {
"properties": {
"product_id": { "type": "keyword" },
"product_title": { "type": "text", "analyzer": "kuromoji" },
"product_brand": { "type": "text", "analyzer": "kuromoji" },
"product_bullet_point": { "type": "text", "analyzer": "kuromoji" },
"product_description": { "type": "text", "analyzer": "kuromoji" },
"candidate_queries": { "type": "keyword" } // どのクエリの候補か
}
}
}
作成したインデックスに、候補商品をPythonで取り出して投入しました。
LLMをつなぐ
SRWは、ML Commonsのコネクタを経由して外部のLLMを呼び出します。ml-commonsのリポジトリにSRW用のGeminiコネクタの例があるので、それを参考にしました。今回はLLMにGemini 3.5 Flash-Liteを使います。APIキーはGoogle AI Studioで発行したものを使います。
まず、コネクタの接続先を許可します。
PUT _cluster/settings
{
"persistent": {
"plugins.ml_commons.trusted_connector_endpoints_regex": [
"^generativelanguage\.googleapis\.com/.*$"
],
"plugins.ml_commons.only_run_on_ml_node": false
}
}
次に、コネクタを登録します。例から変えたのは、タイムアウトと再試行の設定(client_config)と、LLMの応答を読み取る処理(post_process_function)の2か所です。デフォルトのタイムアウト(30秒)と再試行(0回)では足りなかったので延ばしています。また、Gemini 3.5 Flash-Liteでは、採点結果のJSONがエスケープされた形({\"ratings\": ...)で返ってくることがあり、そのままだとSRWが読めずに未採点になってしまうので、replaceで戻してから渡しています。
POST _plugins/_ml/connectors/_create
{
"name": "Gemini gemini-3.5-flash-lite",
"version": "1",
"protocol": "http",
"parameters": { "model": "gemini-3.5-flash-lite" },
"credential": { "gemini_key": "<API キー>" },
"client_config": {
"read_timeout": 180,
"max_retry_times": 5,
"retry_backoff_policy": "exponential_full_jitter",
"retry_backoff_millis": 5000,
"retry_timeout_seconds": 300,
"connection_timeout": 30
},
"actions": [
{
"action_type": "predict",
"method": "POST",
"url": "generativelanguage.googleapis.com",
"headers": {
"Content-Type": "application/json",
"x-goog-api-key": "${credential.gemini_key}"
},
"request_body": "{\"systemInstruction\":{\"parts\":[{\"text\":\"${parameters.system_prompt}\"}]},\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"${parameters.user_prompt}\"}]}]}",
"post_process_function": "def text = params.candidates[0].content.parts[0].text.replace('\\\"', '\"'); return '{\"name\":\"response\",\"dataAsMap\":{\"response\":\"' + escape(text) + '\"}}'"
}
]
}
最後に、モデルとして登録してデプロイします。
POST _plugins/_ml/model_groups/_register
{ "name": "srw_llm_judgment" }
POST _plugins/_ml/models/_register?deploy=true
{
"name": "Gemini gemini-3.5-flash-lite",
"function_name": "remote",
"model_group_id": "<model_group_id>",
"connector_id": "<connector_id>"
}
LLMに採点させてみる
商品データを入れたproducts_kuromojiインデックスで、「電源なしスピーカー」の検索結果の上位5件をLLMに採点させてみます。
まず、クエリセットと検索設定を登録します。検索設定の%SearchText%には、クエリセットのクエリが入ります。
PUT _plugins/_search_relevance/query_sets
{
"name": "handson",
"sampling": "manual",
"querySetQueries": [{ "queryText": "電源なしスピーカー" }]
}
PUT _plugins/_search_relevance/search_configurations
{
"name": "bm25-kuromoji",
"index": "products_kuromoji",
"query": "{\"query\":{\"multi_match\":{\"query\":\"%SearchText%\",\"fields\":[\"product_title^2\",\"product_brand\",\"product_bullet_point\",\"product_description\"]}}}"
}
LLM_JUDGMENTで採点させます。
PUT _plugins/_search_relevance/judgments
{
"name": "handson-score",
"type": "LLM_JUDGMENT",
"modelId": "<model_id>",
"querySetId": "<query_set_id>",
"searchConfigurationList": ["<search_configuration_id>"],
"size": 5, // 検索結果の上位何件を採点するか
"contextFields": ["product_title", "product_brand", "product_bullet_point"], // LLMに渡すフィールド
"llmJudgmentRatingType": "SCORE0_1" // 0〜1の段階評価
}
採点は非同期で進むので、少し待ってから結果を取得します。
GET _plugins/_search_relevance/judgments/<judgment_id>
statusがCOMPLETEDになっていて、商品ごとに点数が付いていれば成功です。一部の商品が採点できなかった場合もCOMPLETEDになるので、failuresに商品が入っていないかも確認します。入っていた場合は、POST _plugins/_search_relevance/judgments/<judgment_id>/_retryで再実行できます。
結果を商品名と並べると、以下のようになりました。
順位
商品(タイトルの要約)
LLM
1
木製無電源スピーカー
1.0
2
PC用スピーカーUSB電源
0.2
3
ポータブルスピーカー(電源不要)
0.8
4
ミニスピーカーUSB電源
0.3
5
木製スピーカー(電源不要)
1.0
電源を使わないスピーカーは0.8〜1.0と高く、USB給電のスピーカーは0.2〜0.3と低くなっていて、「電源なし」を文字どおりに解釈して採点しているようです。
人手ラベルと比べてみる
ESCIには人手で付けた関連度ラベルがあるので、LLMの採点と比べてみます。いくつか試してみた中で、うまくいった例と、人手ラベルとずれた例を見てみます。
LLM_JUDGMENTは、検索結果の上位の商品だけを採点します。今回は人手ラベルと同じ商品で比べたいので、検索の代わりに、そのクエリの候補商品(人手ラベルが付いている商品)をすべて返す検索設定を、先ほどのマッピングで用意したcandidate_queriesを使って作りました。
PUT _plugins/_search_relevance/search_configurations
{
"name": "esci-candidates",
"index": "products_kuromoji",
"query": "{\"query\":{\"term\":{\"candidate_queries\":\"%SearchText%\"}}}"
}
この検索設定を使って、LLM_JUDGMENTを実行します。
PUT _plugins/_search_relevance/judgments
{
"name": "full-score01",
"type": "LLM_JUDGMENT",
"modelId": "<model_id>",
"querySetId": "<query_set_id>",
"searchConfigurationList": ["<esci-candidates の ID>"],
"size": 40, // 候補の最大件数
"tokenLimit": 50000, // 1リクエストのトークン上限で、分割を減らすため大きめに設定
"contextFields": ["product_title", "product_brand", "product_bullet_point"],
"llmJudgmentRatingType": "SCORE0_1"
}
取得した点数を、ESCIの人手ラベルと並べてみます。
人手ラベルと傾向が近かった例:液体ミルク
人手ラベルがEの液体ミルクは、LLMもすべて1.0にしていました。Sの粉ミルクやキューブタイプのミルクは0.3〜0.4で、人手ラベルと整合的な傾向が見られました。
商品(タイトルの要約)
LLM
人手ラベル
明治 ほほえみ らくらくミルク240ml(液体ミルク)
1.0
E
アイクレオ 赤ちゃんミルク125ml(液体ミルク)
1.0
E
明治 ほほえみ らくらくキューブ
0.4
S
和光堂 レーベンスミルク はいはい(粉ミルク)
0.3
S
人手ラベルとずれた例:電源なしスピーカー
先ほど試したクエリです。人手ラベルは多くの商品がEですが、LLMは電源をまったく使わないスピーカーだけを高く採点していました。
商品(タイトルの要約)
LLM
人手ラベル
木製無電源スピーカー
1.0
E
ミニスピーカーUSB電源
0.3
E
Bluetoothスピーカー(充電式)
0.2
E
スピーカーケーブル
0.1
E
人手ラベルでは、USB給電や充電式のスピーカーもEになっています。たとえば「ミニスピーカーUSB電源」は人手ラベルがEですが、LLMは0.3でした。USBから給電する点を、LLMは「電源あり」と判断したと考えられます。同じように「Bluetoothスピーカー(充電式)」もEですが、LLMは0.2で、充電が必要なことから低く採点したようです。「電源なし=コンセントが不要」と読めば、これらはEとも言えるので、ここはLLMと人手の解釈の違いのようです。一方で、「スピーカーケーブル」は人手ラベルがE、LLMは0.1でした。スピーカー本体ではないため、LLMの採点のほうが自然に見えます。ケーブルがEになっている点は解釈の違いでは説明しにくく、人手ラベル自体にも揺れがあるようです。
終わりに
今回は、OpenSearchのSRWのLLM-as-a-Judgeを日本語の商品データで試してみました。Gemini 3.5 Flash-Liteでの採点は、人手ラベルと傾向が近いクエリもあれば、大きくずれるクエリもあり、結果はモデルによっても変わりそうです。今後としては、上位のモデルやプロンプトを変えて、より多くのクエリで比較してみたいです。
参考
amazon-science/esci-data(ライセンス:Apache-2.0)
Reddy et al., Shopping Queries Dataset: A Large-Scale ESCI Benchmark for Improving Product Search (2022)
OpenSearch Docs: Search Relevance Workbench
OpenSearch Docs: Judgments
ml-commons: Google Gemini connector blueprint for LLM judgment
opensearchコンテナから、fluentdの情報+sysstatの情報を収集する
<!--
Qiita投稿用メモ(本文には含めない)
タイトル案: OpenSearch 3.x + Fluentd で journald のログを集める自前ログ基盤を一から作る(Docker / TLS / 送信元IP制限)
タグ案: OpenSearch, Fluentd, Docker, Ubuntu, journald
-->
この記事について
本記事はAIで情報をまとめている。
複数台のUbuntuサーバーが出力するログを、1台のOpenSearchに集約するログ基盤の構築手順をまとめる。
ログの送信にはFluentdを使い、journaldに流れているアプリケーションログとシステムログ、さらにsysstatのリソース使用状況まで取り込む。
実際に構築した際に何度もつまずいた箇所があったため、手順の中にその理由と確認コマンドを埋め込んである。
上から順に実行すれば、初めての人でも同じ構成を再現できることを目標にしている。
なお、OpenSearch Dashboards上でグラフやダッシュボードを作る手順は扱わない。
本記事のゴールは、ログがOpenSearchに届き、DashboardsのDiscover画面で検索できる状態までである。
:::note info
記事中のIPアドレス、ホスト名、ユーザー名、パスワードはすべてダミーまたはプレースホルダーに置き換えている。
<LOG_SERVER_IP> のように < > で囲まれた部分は、自分の環境の値に読み替えること。
:::
構成と前提
全体構成
[app01] Fluentd ─┐
[app02] Fluentd ─┼── HTTPS(9200) + Basic認証 ──> [log-server] OpenSearch
[app03] Fluentd ─┘ 送信元IPで制限 │
│ コンテナ間通信
v
[手元PC] ── SSHトンネル(5601) ─────────────────> [log-server] OpenSearch Dashboards
| 役割 | ホスト名(本記事での呼称) | 備考 |
| --- | --- | --- |
| ログ集約サーバー | log-server | OpenSearchとDashboardsをDockerで動かす |
| ログ送信元サーバー | app01 app02 app03 | Fluentdを導入する |
| 閲覧者の手元PC | - | SSHトンネル経由でDashboardsを見る |
使用するソフトウェアとバージョン
| ソフトウェア | バージョン |
| --- | --- |
| OS | Ubuntu 22.04 LTS(jammy)。送信元の一部が20.04(focal)でも可 |
| OpenSearch / OpenSearch Dashboards | 3.8.0(公式Dockerイメージ) |
| Fluentd | fluent-package 6 LTS(focalでは5 LTS) |
| Fluentdプラグイン | fluent-plugin-systemd、fluent-plugin-opensearch |
設計上の判断
OpenSearchの9200番ポートは、ログ集約サーバーのグローバルIP上で待ち受ける。
そのうえで、送信元サーバーのIPアドレス以外からの接続をファイアウォールで遮断し、通信自体はTLSとBasic認証で保護する。
ドメインを割り当てていないため、証明書は自己署名で作成する。
ドメインを取得できる環境であれば、Let's Encrypt等の正式な証明書に切り替えることを推奨する。
Dashboardsはループバックアドレスでのみ待ち受け、外部には公開しない。
閲覧する際はSSHのポートフォワードを使う。
ログは送信元サーバーごとにインデックスを分けず、共有のインデックスに格納する。
どのサーバーのログかは、Fluentdが付与する hostname フィールドで区別する。
この方式にすると、Fluentdの設定ファイルを全サーバーで同一の内容にできる。
作成するインデックス
| インデックス名 | 内容 | 保持期間(初期値) |
| --- | --- | --- |
| logs-app-YYYY.MM.DD | アプリケーション(myapp@*.service)のログ | 31日 |
| logs-system-YYYY.MM.DD | journald全体のシステムログ(任意) | 7日 |
| logs-sysstat-YYYY.MM.DD | CPU、メモリ、ディスクI/Oの使用状況(任意) | 14日 |
前提: アプリケーションのログがjournaldに流れていること
本記事では、送信元サーバーで docker compose up の標準出力がjournaldに流れている構成を前提とする。
すでに同様の仕組みがある場合は、この節は読み飛ばしてよい。
まだ仕組みがない場合は、以下のようなsystemdのテンプレートユニットを用意すると、/opt/apps/<アプリ名>/ に置いた docker-compose.yml を myapp@<アプリ名>.service として起動でき、その出力がjournaldに記録される。
【app01 / app02 / app03】
sudo tee /etc/systemd/system/myapp@.service > /dev/null <<'EOF'
[Unit]
Description=myapp %i (docker compose)
After=docker.service
Requires=docker.service
[Service]
WorkingDirectory=/opt/apps/%i
ExecStart=/usr/bin/docker compose up --no-color
ExecStop=/usr/bin/docker compose down
Restart=always
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now myapp@testapp.service
journalctl -u myapp@testapp.service -n 5 --no-pager
docker compose up の出力は testapp-web-1 | GET / 200 のように、コンテナ名と本文が | で区切られた形式になる。
後の手順で、Fluentdはこの | を境に container_name と log_message の2つのフィールドに分割する。
---
1. 送信元サーバーのグローバルIPを確認する
ログ集約サーバーのファイアウォールに登録するため、各送信元サーバーのグローバルIPを確認しておく。
【app01 / app02 / app03(それぞれで実行)】
curl -s https://ifconfig.me; echo
以降、それぞれ <APP01_IP> <APP02_IP> <APP03_IP> と表記する。
:::note warn
送信元のグローバルIPが変動する契約の場合、IPアドレスで制限するこの方式は成り立たない。
固定IPであることを事前に確認しておくこと。
:::
---
2. ログ集約サーバーにDockerを導入する
Docker公式の「Install using the apt repository」の手順に従う。
すでにDockerとDocker Compose v2が導入済みであれば、この章は不要である。
参考: Install Docker Engine on Ubuntu | Docker Docs
【log-server】
# 競合する旧パッケージの削除(入っていなければエラーは無視してよい)
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt remove -y $pkg 2>/dev/null || true
done
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
一度ログアウトして再ログインし、バージョンが表示されることを確認する。
【log-server】
docker --version
docker compose version
---
3. ログ集約サーバーのカーネルパラメータを設定する
OpenSearchは vm.max_map_count が262144以上であることを必須要件としている。
これを満たさないと、コンテナが起動直後に停止する。
参考: Important system settings - OpenSearch Documentation
【log-server】
sudo sysctl -w vm.max_map_count=262144
echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf
---
4. ログ集約サーバーのファイアウォールを設定する
Dockerの公開ポートがufwを経由しない問題
この章は構築全体の中で最も見落としやすい。
Dockerで ports: "9200:9200" のようにポートを公開すると、DockerはiptablesのNATテーブルとFORWARDチェーンに直接ルールを追加する。
一方、ufwのルールはINPUTチェーンに書き込まれる。
そのため、公開ポート宛てのパケットはufwのルールを通らずにコンテナへ届いてしまう。
つまり ufw allow from <IP> to any port 9200 と設定しても、実際には全世界から9200番に接続できる状態のままになる。
これはDocker社自身も認識している既知の挙動である。
対策として、Dockerが自身のルールより先に評価する DOCKER-USER チェーンにルールを入れる。
このチェーンはDockerの起動時に上書きされないため、ufwの起動時に読み込まれる /etc/ufw/after.rules に書いて永続化する。
参考:
- Best Practices for Docker and UFW · Issue #777 · docker/for-linux
- chaifeng/ufw-docker
- Docker Container Published Port Ignoring UFW Rules | Baeldung on Linux
SSHなど通常のポートはufwで制御する
Dockerで公開していないポートは、通常通りufwで制御できる。
SSHの接続元を限定できる場合は、ufw allow OpenSSH で全開放するより、送信元IPを絞るほうが安全である。
【log-server】
sudo ufw status verbose
sudo ufw allow from <YOUR_ADMIN_IP> to any port <SSH_PORT> proto tcp
sudo ufw enable
:::note warn
ufw enable の前に、今使っているSSH接続が許可ルールに含まれていることを必ず確認すること。
誤ると自分自身を締め出してしまう。
:::
9200番はDOCKER-USERチェーンで制御する
【log-server】
sudo vi /etc/ufw/after.rules
ファイルの末尾に以下を追記する。
送信元サーバーの台数に合わせて ACCEPT の行を増減させる。
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -s <APP01_IP>/32 -p tcp --dport 9200 -j ACCEPT
-A DOCKER-USER -s <APP02_IP>/32 -p tcp --dport 9200 -j ACCEPT
-A DOCKER-USER -s <APP03_IP>/32 -p tcp --dport 9200 -j ACCEPT
-A DOCKER-USER -p tcp --dport 9200 -j DROP
-A DOCKER-USER -j RETURN
COMMIT
反映し、DOCKER-USERチェーンに意図したルールが入っているか確認する。
【log-server】
sudo ufw reload
sudo iptables -L DOCKER-USER -n -v
期待される結果: 送信元IPごとの ACCEPT の行と、それ以外を落とす DROP の行が表示されること。
:::note info
Dockerのバージョンアップやサーバーの再起動のあとは、sudo iptables -L DOCKER-USER -n -v でルールが残っているか再確認する習慣をつけておくとよい。
ufw status の表示だけでは、このチェーンの状態は分からない。
:::
---
5. TLS証明書を作成する(自己署名)
参考: Generating self-signed certificates - OpenSearch Documentation
作業用のディレクトリを作成する。
以降、OpenSearch関連のファイルはすべて /opt/log-stack に置く。
【log-server】
sudo mkdir -p /opt/log-stack/certs
sudo chown -R $USER:$USER /opt/log-stack
cd /opt/log-stack/certs
ルートCA
【log-server】
openssl genrsa -out root-ca-key.pem 2048
openssl req -new -x509 -sha256 -key root-ca-key.pem -out root-ca.pem -days 3650 \
-subj "/C=JP/ST=Tokyo/O=Example/CN=example-log-root-ca"
ノード証明書
FluentdはTLS接続時に、接続先のアドレスと証明書の内容が一致するかを検証する。
本記事ではIPアドレスで接続するため、CNとSAN(subjectAltName)の両方にログ集約サーバーのグローバルIPを入れる。
【log-server】
openssl genrsa -out node-key-temp.pem 2048
openssl pkcs8 -inform PEM -outform PEM -in node-key-temp.pem -topk8 -nocrypt -v1 PBE-SHA1-3DES -out node-key.pem
openssl req -new -key node-key.pem -out node.csr \
-subj "/C=JP/ST=Tokyo/O=Example/CN=<LOG_SERVER_IP>"
echo "subjectAltName=IP:<LOG_SERVER_IP>" > node-san.ext
openssl x509 -req -in node.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \
-sha256 -out node.pem -days 3650 -extfile node-san.ext
管理者証明書
管理者証明書は、パスワードを使わずにOpenSearchのセキュリティ設定を確認、変更するために使う。
パスワード認証でつまずいた際の切り分けにも役立つ。
【log-server】
openssl genrsa -out admin-key-temp.pem 2048
openssl pkcs8 -inform PEM -outform PEM -in admin-key-temp.pem -topk8 -nocrypt -v1 PBE-SHA1-3DES -out admin-key.pem
openssl req -new -key admin-key.pem -out admin.csr \
-subj "/C=JP/ST=Tokyo/O=Example/CN=admin"
openssl x509 -req -in admin.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \
-sha256 -out admin.pem -days 3650
証明書のDN文字列を取得する
-subj で指定した文字列と、証明書に実際に埋め込まれるDN(識別名)は、順序も区切り文字も一致しない。
後で opensearch.yml に書く admin_dn と nodes_dn には、以下のコマンドで取得した文字列を使う必要がある。
【log-server】
openssl x509 -subject -nameopt RFC2253 -noout -in node.pem
openssl x509 -subject -nameopt RFC2253 -noout -in admin.pem
出力は以下のように subject= という接頭辞付きで表示される。
subject=CN=<LOG_SERVER_IP>,O=Example,ST=Tokyo,C=JP
subject=CN=admin,O=Example,ST=Tokyo,C=JP
:::note warn
opensearch.yml に書くのは subject= を取り除いた CN=...,O=...,ST=...,C=JP の部分だけである。
subject= を含めたまま書くと、証明書による権限チェックに失敗する。
:::
不要ファイルの削除とパーミッション設定
【log-server】
rm -f node-key-temp.pem admin-key-temp.pem *.csr node-san.ext
chmod 644 *.pem
cd /opt/log-stack
パーミッションを 600 ではなく 644 にしているのには理由がある。
OpenSearchの公式Dockerイメージはコンテナ内部で非rootユーザーとして動作するため、600 のままだと Unable to read the file root-ca.pem のようなエラーで起動に失敗する。
---
6. 管理者パスワードのハッシュ値を生成する
なぜ自分でハッシュを作るのか
OpenSearchの公式イメージには、OPENSEARCH_INITIAL_ADMIN_PASSWORD という環境変数で管理者パスワードを設定する仕組みがある。
しかしこれを担っているのは、イメージ内蔵の「デモ設定インストーラー」である。
本記事のように独自の opensearch.yml を用意すると、このインストーラーは opensearch.yml seems to be already configured for Security. Quit. と出力して処理をスキップする。
その結果、環境変数にパスワードを書いても一切反映されない。
そのため、パスワードのハッシュ値は自分で生成し、internal_users.yml に直接書き込む。
参考:
- Seems to be already configured for Security. Quit - OpenSearch Forum
- Modifying the YAML files | OpenSearch Documentation
ハッシュの生成
イメージに同梱されている hash.sh を、使い捨てコンテナで実行する。
admin 用と、Dashboardsが内部通信に使う kibanaserver 用の2つを生成する。
【log-server】
# admin用(大文字、小文字、数字、記号を含む十分に長いパスワードにする)
docker run --rm opensearchproject/opensearch:3.8.0 \
/usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p '<ADMIN_PASSWORD>'
# kibanaserver用
docker run --rm opensearchproject/opensearch:3.8.0 \
/usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p '<KIBANASERVER_PASSWORD>'
期待される結果: それぞれ $2y$12$... で始まるbcryptハッシュ文字列が1行出力される。
次の章で使うので、2つともコピーしておく。
---
7. 設定ファイルを作成する
internal_users.yml
admin と kibanaserver の2ユーザーを定義する。
kibanaserver が無いと、Dashboardsが OpenSearch Dashboards server is not ready yet と表示したまま起動しない。
:::note warn
<ADMIN_HASH> と <KIBANASERVER_HASH> は、前章で生成した $2y$12$... の文字列に必ず置き換えてから実行すること。
ハッシュ値には $ が含まれるため、シェル展開を防ぐ <<'EOF'(クォート付き)のヒアドキュメントで書き込んでいる。
:::
【log-server】
tee /opt/log-stack/internal_users.yml > /dev/null <<'EOF'
---
_meta:
type: "internalusers"
config_version: 2
admin:
hash: "<ADMIN_HASH>"
reserved: true
backend_roles:
- "admin"
description: "Admin user"
kibanaserver:
hash: "<KIBANASERVER_HASH>"
reserved: true
description: "OpenSearch Dashboards internal user"
EOF
chmod 644 /opt/log-stack/internal_users.yml
プレースホルダーや不可視文字が残っていないか確認する。
【log-server】
cat -A /opt/log-stack/internal_users.yml | grep -A1 "^admin:\|^kibanaserver:"
期待される結果: hash: "$2y$12$..."$ のように、$2y$12$ から始まる文字列がそのまま表示されること。
M-c のような文字化けや、<ADMIN_HASH> の文字が残っていないこと。
opensearch.yml
参考: Security configuration | OpenSearch Documentation
admin_dn と nodes_dn には、5章で取得したDN文字列(subject= を除いた部分)を書く。
【log-server】
tee /opt/log-stack/opensearch.yml > /dev/null <<'EOF'
cluster.name: example-log-cluster
network.host: 0.0.0.0
discovery.type: single-node
plugins.security.disabled: false
plugins.security.restapi.roles_enabled: ["all_access"]
plugins.security.ssl.transport.pemcert_filepath: node.pem
plugins.security.ssl.transport.pemkey_filepath: node-key.pem
plugins.security.ssl.transport.pemtrustedcas_filepath: root-ca.pem
plugins.security.ssl.transport.enforce_hostname_verification: false
plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.pemcert_filepath: node.pem
plugins.security.ssl.http.pemkey_filepath: node-key.pem
plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem
plugins.security.authcz.admin_dn:
- CN=admin,O=Example,ST=Tokyo,C=JP
plugins.security.nodes_dn:
- CN=<LOG_SERVER_IP>,O=Example,ST=Tokyo,C=JP
plugins.security.allow_default_init_securityindex: true
EOF
plugins.security.restapi.roles_enabled は省略しやすいが必須である。
これが無いと、admin が all_access ロールを持っていても、セキュリティ管理用のREST API(ロールやユーザーの作成)が Role based access not enabled で拒否される。
次の章以降でFluentd用のユーザーをAPIで作成するため、この設定が欠かせない。
参考: Cannot see Security Dashboard tab in Opensearch Dashboards under plugins - OpenSearch Forum
opensearch_dashboards.yml
独自の opensearch_dashboards.yml をマウントすると、イメージ内蔵のデフォルト設定は丸ごと置き換わる。
そのため、Dashboardsが内部通信に使う kibanaserver の認証情報も自分で書く必要がある。
csp.rules は、Dashboardsのセキュリティ管理画面がインラインスクリプトをブロックされて Application Not Found と表示される問題への対策である。
【log-server】
tee /opt/log-stack/opensearch_dashboards.yml > /dev/null <<'EOF'
server.host: "0.0.0.0"
opensearch.hosts: ["https://opensearch:9200"]
opensearch.ssl.verificationMode: none
opensearch.username: "kibanaserver"
opensearch.password: "<KIBANASERVER_PASSWORD>"
csp.rules:
- "script-src 'unsafe-eval' 'self' 'unsafe-inline'"
opensearch.requestHeadersAllowlist: ["authorization", "securitytenant"]
opensearch_security.multitenancy.enabled: true
opensearch_security.multitenancy.tenants.preferred: ["Global", "Private"]
EOF
末尾の3行は、マルチテナント(テナント機能)を有効にする設定である。
テナントは、Index Patternやダッシュボードなどの保存オブジェクトを共有する範囲のことで、16章で作る閲覧専用ユーザーの権限制御に使う。
イメージ内蔵のデフォルトファイルにはこの設定が含まれているが、独自のファイルで置き換えると一緒に消え、マルチテナントは無効になる。
無効のままだと、Dashboardsの画面に Switch tenants が表示されず、16章で設定するテナントの読み取り専用権限も効かない。
| 設定 | 役割 |
| --- | --- |
| opensearch.requestHeadersAllowlist | どのテナントを見ているかを示す securitytenant ヘッダーを、DashboardsからOpenSearchへ渡す。これが無いとテナントを切り替えても反映されない |
| opensearch_security.multitenancy.enabled | テナント機能を有効にする |
| opensearch_security.multitenancy.tenants.preferred | ログイン時に表示されるテナントの並び順 |
:::note info
opensearch.ssl.verificationMode: none は、DashboardsからOpenSearchへの内部通信で証明書を検証しない設定である。
両者は同じDockerネットワーク内で通信するため実害は小さいが、より厳密にしたい場合は opensearch.ssl.certificateAuthorities にCA証明書を指定し、verificationMode: full にする。
:::
docker-compose.yml
OpenSearchのデータは、Dockerの名前付きボリュームではなく、容量に余裕のあるディスク上のディレクトリにbind mountする。
名前付きボリュームは既定で /var/lib/docker/ 配下、つまりルートファイルシステム上に作られるため、ログの蓄積とともに容量不足になりやすい。
本記事では、大容量ディスクを /data にマウントしている想定とする。
OpenSearchのコンテナはUID/GID 1000で動作するため、データディレクトリの所有者を合わせておく。
【log-server】
sudo mkdir -p /data/opensearch
sudo chown -R 1000:1000 /data/opensearch
【log-server】
tee /opt/log-stack/docker-compose.yml > /dev/null <<'EOF'
services:
opensearch:
image: opensearchproject/opensearch:3.8.0
container_name: opensearch
environment:
- discovery.type=single-node
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- /data/opensearch:/usr/share/opensearch/data
- ./certs/root-ca.pem:/usr/share/opensearch/config/root-ca.pem
- ./certs/node.pem:/usr/share/opensearch/config/node.pem
- ./certs/node-key.pem:/usr/share/opensearch/config/node-key.pem
- ./certs/admin.pem:/usr/share/opensearch/config/admin.pem
- ./certs/admin-key.pem:/usr/share/opensearch/config/admin-key.pem
- ./opensearch.yml:/usr/share/opensearch/config/opensearch.yml
- ./internal_users.yml:/usr/share/opensearch/config/opensearch-security/internal_users.yml
ports:
- "9200:9200"
networks:
- log-net
restart: unless-stopped
dashboards:
image: opensearchproject/opensearch-dashboards:3.8.0
container_name: dashboards
environment:
- 'OPENSEARCH_HOSTS=["https://opensearch:9200"]'
volumes:
- ./certs/root-ca.pem:/usr/share/opensearch-dashboards/config/root-ca.pem
- ./opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml
ports:
- "127.0.0.1:5601:5601"
networks:
- log-net
depends_on:
- opensearch
restart: unless-stopped
networks:
log-net:
driver: bridge
EOF
-Xms2g -Xmx2g はJVMのヒープサイズである。
サーバーのメモリ量の半分程度を上限の目安に調整する。
書き込んだ直後に、internal_users.yml と opensearch_dashboards.yml のマウント行が含まれているか確認する。
この確認を怠ると、ファイルを正しく編集してもコンテナに渡らず、「何度直しても認証が通らない」という分かりにくい不具合の原因になる。
【log-server】
grep -E "internal_users.yml|opensearch_dashboards.yml" /opt/log-stack/docker-compose.yml
期待される結果: 2行が表示されること。
---
8. OpenSearchを起動して疎通を確認する
【log-server】
cd /opt/log-stack
docker compose up -d
docker compose logs -f opensearch
"message":"started" のようなログが出たら Ctrl+C で抜ける。
管理者でのログイン確認
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' https://127.0.0.1:9200
期待される結果: "cluster_name" : "example-log-cluster" を含むJSONが返ること。
-k は証明書の検証を省略するオプションである。
証明書には 127.0.0.1 ではなくグローバルIPが入っているため、ログ集約サーバー上で 127.0.0.1 宛てに確認する場合は -k を付ける。
セキュリティ管理APIの確認
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' https://127.0.0.1:9200/_plugins/_security/api/securityconfig
期待される結果: FORBIDDEN ではなく、authc などの設定を含むJSONが返ること。
Role based access not enabled が返る場合は、opensearch.yml の plugins.security.restapi.roles_enabled が反映されていない。
Unauthorizedが返る場合の切り分け
ハッシュの貼り間違いやマウント漏れなど、原因の候補が複数あるため、管理者証明書を使ってパスワードに頼らずに中身を直接確認するのが確実である。
【log-server】
docker exec -it opensearch curl -s -k \
--cacert /usr/share/opensearch/config/root-ca.pem \
--cert /usr/share/opensearch/config/admin.pem \
--key /usr/share/opensearch/config/admin-key.pem \
https://localhost:9200/_plugins/_security/api/internalusers/admin
"description" が "Admin user" になっていれば、自分で用意した internal_users.yml が読み込まれている。
"Demo admin user" のような覚えのない値が返る場合は、internal_users.yml がコンテナに渡っていない。
:::note warn
OpenSearchのセキュリティ設定(.opendistro_security インデックス)は、初回起動時に一度だけ internal_users.yml から初期化される。
初回起動後に internal_users.yml を修正しても、コンテナの再起動だけでは反映されない。
構築中にやり直す場合は、docker compose down のあと /data/opensearch の中身を削除してから起動し直す。
:::
---
9. Fluentd専用の書き込みユーザーを作成する
Fluentdには admin を使わせず、ログの書き込みだけができる専用ユーザー fluentd を作成する。
DashboardsのGUIからも作成できるが、本記事では手順の再現性を優先してREST APIで作成する。
参考: Getting started with OpenSearch security | OpenSearch Documentation
ロールの作成
後で任意で追加する logs-system-* と logs-sysstat-* の分も、最初からまとめて許可しておく。
使わないインデックスパターンが含まれていても害はない。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/roles/fluentd_writer" \
-H 'Content-Type: application/json' \
-d '{
"cluster_permissions": ["cluster_monitor", "indices:data/write/bulk"],
"index_permissions": [
{
"index_patterns": ["logs-app-*", "logs-system-*", "logs-sysstat-*"],
"allowed_actions": ["create_index", "write", "indices:admin/mapping/put"]
}
]
}'
期待される結果: "status":"CREATED" を含むJSONが返ること。
Cluster permissionsの2つは、どちらも欠けると書き込みが失敗する。
| 権限 | 必要な理由 |
| --- | --- |
| cluster_monitor | fluent-plugin-opensearchは接続時にOpenSearchのバージョンを確認する。この確認に必要な cluster:monitor/main が無いと 403 security_exception でリトライし続け、書き込みまで到達しない |
| indices:data/write/bulk | ログの書き込みは内部的にこのアクションとして処理され、まずクラスタレベルで許可チェックが行われる。これが無いと、Index permissions側で write を付けていても 403 になる |
2つ目が欠けている場合、docker logs opensearch に No cluster-level perm match for User ... [Action [indices:data/write/bulk]] という形で記録される。
ユーザーの作成
パスワードは、大文字、小文字、数字、記号を含む十分に長いものにする。
弱いパスワードはAPI側で拒否される。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/internalusers/fluentd" \
-H 'Content-Type: application/json' \
-d '{"password": "<FLUENTD_PASSWORD>"}'
ロールとユーザーの紐付け
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/rolesmapping/fluentd_writer" \
-H 'Content-Type: application/json' \
-d '{"users": ["fluentd"]}'
動作確認
認証とロールの紐付けを確認する。
【log-server】
curl -k -u 'fluentd:<FLUENTD_PASSWORD>' https://127.0.0.1:9200/_plugins/_security/api/account
期待される結果: "roles":["own_index","fluentd_writer"] を含むJSONが返ること。
own_index は全ユーザーに自動付与される既定のロールなので気にしなくてよい。
:::note info
https://127.0.0.1:9200(ルートパス)に fluentd で接続すると、パスワードが合っていても 403 が返ることがある。
これは認証失敗ではなく、権限が書き込みに絞られているための正常な挙動である。
401(認証失敗)と403(権限不足)を混同しないこと。
:::
続けて、テスト用ドキュメントを1件書き込み、書き込み権限を確認する。
【log-server】
curl -k -u 'fluentd:<FLUENTD_PASSWORD>' -X POST \
"https://127.0.0.1:9200/logs-app-test/_doc" \
-H 'Content-Type: application/json' \
-d '{"message": "connectivity test"}'
期待される結果: "result":"created" を含むJSONが返ること。
確認できたら、テスト用のインデックスを削除する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X DELETE "https://127.0.0.1:9200/logs-app-test"
---
10. 送信元サーバーにFluentdを導入する
参考: Install by .deb Package (Debian/Ubuntu) | Fluentd
インストールスクリプトはOSのバージョンごとに異なる。
誤ったスクリプトを実行すると、依存パッケージのバージョンが合わず Unable to correct problems, you have held broken packages で失敗する。
最初にOSのコードネームを確認する。
【app01 / app02 / app03(それぞれで実行)】
lsb_release -cs
jammy(22.04)の場合は以下を実行する。
【jammyのサーバー】
curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-jammy-fluent-package6-lts.sh | sh
sudo /opt/fluent/bin/fluent-gem install fluent-plugin-systemd fluent-plugin-opensearch
focal(20.04)の場合は以下を実行する。
fluent-package 6はfocal向けに提供されていないため、5 LTSを使う。
インストール先(/opt/fluent/bin)は共通なので、以降の手順に違いはない。
【focalのサーバー】
curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-focal-fluent-package5-lts.sh | sh
sudo /opt/fluent/bin/fluent-gem install fluent-plugin-systemd fluent-plugin-opensearch
インストール中に Pending kernel upgrade のようなダイアログが出ることがある。
カーネル更新に関する別件の通知なので、<Ok> を選んで進めてよい。
実行ユーザーをsystemd-journalグループに追加する
fluent-packageは _fluentd という非rootユーザーでサービスを実行する。
journaldを読み取るには systemd-journal グループへの所属が必要だが、インストール直後は所属していない。
【app01 / app02 / app03(それぞれで実行)】
sudo usermod -aG systemd-journal _fluentd
id _fluentd
期待される結果: groups= の中に systemd-journal が含まれること。
:::note warn
この設定を忘れると、Fluentdはエラーを一切出さずに、journaldから何も読めないまま動き続ける。
気づきにくい不具合なので、必ず先に設定しておくこと。
:::
---
11. 送信元サーバーにCA証明書を配布する
Fluentdがログ集約サーバーの証明書を検証できるよう、ルートCA証明書(root-ca.pem)を各送信元サーバーに配置する。
配布するのは公開鍵側の root-ca.pem だけであり、root-ca-key.pem などの秘密鍵は絶対にコピーしないこと。
【app01 / app02 / app03(それぞれで実行)】
sudo mkdir -p /etc/fluent/certs
scp -P <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>:/opt/log-stack/certs/root-ca.pem /tmp/root-ca.pem
sudo mv /tmp/root-ca.pem /etc/fluent/certs/root-ca.pem
sudo chmod 644 /etc/fluent/certs/root-ca.pem
ログ集約サーバーへのSSHが踏み台サーバー経由に限られている場合は、-o ProxyJump=<YOUR_USER>@<BASTION_IP>:<BASTION_SSH_PORT> を付けて多段接続する。
証明書が使えるか、送信元サーバーから確認しておく。
ここでは -k を付けず、配布したCA証明書で検証する。
【app01 / app02 / app03(それぞれで実行)】
curl --cacert /etc/fluent/certs/root-ca.pem -u 'fluentd:<FLUENTD_PASSWORD>' \
https://<LOG_SERVER_IP>:9200/_plugins/_security/api/account
期待される結果: fluentd_writer を含むJSONが返ること。
SSL: no alternative certificate subject name matches のようなエラーが出る場合は、5章でノード証明書に入れたIPアドレスと、接続先のIPアドレスが一致していない。
---
12. Fluentdの設定ファイルを作成する
設定ファイル全体を示す前に、つまずいた箇所とその理由を説明しておく。
matchesはワイルドカードに対応していない
fluent-plugin-systemdには、取り込むログを絞る matches というパラメータがある。
matches [{ "_SYSTEMD_UNIT": "myapp@*.service" }]
しかし matches はjournald本体のフィルタ機能をそのまま使っており、完全一致でしか比較しない。
myapp@*.service という文字列は * を含む文字列そのものとして扱われ、実際のユニット名には一致しない。
そのため本記事では matches を使わず、全ジャーナルを読み込んだうえで、後段の grep フィルタで正規表現による絞り込みを行う。
journaldの挙動は以下で確認できる。
journalctl _SYSTEMD_UNIT="myapp@*.service" -n 5 --no-pager # 0件
journalctl _SYSTEMD_UNIT="myapp@testapp.service" -n 5 --no-pager # 表示される
read_from_headはfalseにする
read_from_head true は、journaldに保存されている最も古いログから順に読む指定である。
長期間稼働しているサーバーには数か月から数年分の履歴が残っていることがあり、Fluentdはそれを最初から読み進める。
その間は新しいログに到達できず、エラーも出ないまま何も送信されていないように見える。
導入時点から先のログを集めれば十分なので、false にする。
日付入りのインデックス名には <buffer time> が必要
index_name logs-app-%Y.%m.%d のように日付のプレースホルダーを使う場合、バッファのチャンクキーに time を指定し、timekey を設定する必要がある。
これを忘れると、プレースホルダーが展開されず logs-app-%Y.%m.%d という名前のインデックスがそのまま作られる。
また <buffer time> にすると、既定の flush_mode が lazy(timekeyの区切りまで送信しない)に変わり、flush_interval が無視される。
数秒おきに送信させたいので、flush_mode interval を明示する。
:::note warn
運用開始後に <buffer> を <buffer time> に変更するなど、チャンクキーの構造を変えた場合は、古いバッファが残っていると unexpected error while checking flushed chunks で詰まることがある。
その場合は sudo systemctl stop fluentd のあと、/var/log/fluent/buffer/ 配下の該当ディレクトリを空にしてから起動し直す。
:::
@timestampは自分で付与する
fluent-plugin-opensearchが @timestamp を自動で付けるのは、logstash_format true のときだけである。
本記事ではインデックス名を自分で指定するため logstash_format false にしており、@timestamp も付かなくなる。
@timestamp が無いとDashboardsで時刻による絞り込みができないため、record_transformer で付与する。
hostnameフィールドでサーバーを区別する
record_transformer で hostname ${Socket.gethostname} を付与する。
実行中のホスト名が自動で入るため、設定ファイルは全サーバーで同一の内容のまま使える。
パスワードはダブルクォートで囲む
Fluentdの設定ファイルでは、クォート無しの値に # が含まれると、それ以降がコメントとして切り捨てられることがある。
パスワードは必ずダブルクォートで囲む。
設定ファイル全体
<LOG_SERVER_IP> と <FLUENTD_PASSWORD> を自分の値に置き換えて実行する。
host には、5章でノード証明書に入れたIPアドレスと同じ値を書く。
【app01 / app02 / app03(それぞれで実行)】
sudo tee /etc/fluent/fluentd.conf > /dev/null <<'EOF'
<source>
@type systemd
path /var/log/journal
read_from_head false
tag app.journal
<storage>
@type local
persistent true
path /var/log/fluent/systemd-app.pos
</storage>
<entry>
fields_strip_underscores true
</entry>
</source>
<filter app.journal>
@type grep
<regexp>
key SYSTEMD_UNIT
pattern /^myapp@.*\.service$/
</regexp>
</filter>
<filter app.journal>
@type record_transformer
enable_ruby true
<record>
container_name ${record["MESSAGE"].to_s.split("|", 2)[0].to_s.strip}
log_message ${record["MESSAGE"].to_s.split("|", 2)[1].to_s.strip}
"@timestamp" ${Time.at(time.to_i).strftime('%Y-%m-%dT%H:%M:%S%:z')}
hostname ${Socket.gethostname}
</record>
</filter>
<match app.journal>
@type opensearch
host <LOG_SERVER_IP>
port 9200
scheme https
ssl_verify true
ca_file /etc/fluent/certs/root-ca.pem
user fluentd
password "<FLUENTD_PASSWORD>"
index_name logs-app-%Y.%m.%d
logstash_format false
reconnect_on_error true
reload_on_failure true
<buffer time>
@type file
timekey 1d
flush_mode interval
path /var/log/fluent/buffer/opensearch-app
flush_interval 5s
retry_max_times 10
retry_wait 5s
chunk_limit_size 8MB
</buffer>
</match>
EOF
fields_strip_underscores true により、journaldのフィールド名 _SYSTEMD_UNIT は先頭のアンダースコアが外れて SYSTEMD_UNIT になる。
grep フィルタの key は外れた後の名前に合わせてある。
参考:
- fluent-plugin-systemd README
- fluent-plugin-opensearch README
---
13. Fluentdを起動して到達を確認する
設定ファイルの文法を確認してから起動する。
【app01 / app02 / app03(それぞれで実行)】
sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf
sudo systemctl restart fluentd
sudo systemctl status fluentd
対象サービスのログを出すため、アプリケーションに何度かアクセスするか、サービスを再起動する。
数十秒待ってから、ログ集約サーバーでインデックスを確認する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/_cat/indices/logs-*?v"
期待される結果: logs-app-YYYY.MM.DD が表示されること。
1件取り出して、フィールドの中身も確認する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/logs-app-*/_search?pretty&size=1"
期待される結果: _source の中に @timestamp、hostname、container_name、log_message が含まれていること。
届かない場合の切り分け
エラーが出ないのに届いていない、という状態になりやすいため、以下の順に確認する。
1. バッファディレクトリの中身
ls -la /var/log/fluent/buffer/opensearch-app/
空のままであれば、<source> か <filter> の段階でデータが流れていない。
ファイルがあるのに送信されていなければ、<match>(OpenSearchへの送信)側の問題である。
2. position(読み取り位置)ファイルの中身
sudo cat /var/log/fluent/systemd-app.pos/worker0/storage.json
{} のままであれば、journaldから1件も読めていない。
{"journal":"s=..."} のようなカーソル情報が入っていれば、読み取りは進んでいる。
3. 実行ユーザーでjournaldを読めるか
sudo runuser -u _fluentd -- journalctl -u myapp@testapp.service -n 5 --no-pager
権限エラーになる場合は、10章の systemd-journal グループへの追加が反映されていない。
グループ追加後にFluentdを再起動していない場合によく起きる。
4. Fluentdのログ
sudo tail -60 /var/log/fluent/fluentd.log
| ログに出る内容 | 疑うべき箇所 |
| --- | --- |
| security_exception、403 | 9章のロールのCluster permissions |
| 401、Unauthorized | fluentd.conf のパスワードと9章で設定したパスワードの不一致 |
| SSLError、hostname does not match | 証明書のIPと host の不一致(5章、11章) |
| unexpected error while checking flushed chunks | バッファ構造の変更後に残った古いチャンク(12章) |
| has timestamp placeholders, but chunk key 'time' is not configured | <buffer time> の指定漏れ(12章) |
---
14. 古いインデックスを自動削除する(ISM)
ログは日ごとにインデックスが増え続けるため、Index State Management(ISM)で一定期間後に自動削除する。
logs-app-* は、7日経過で読み取り専用(warm)にし、31日経過で削除する。
参考: ISM API | OpenSearch Documentation
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-app-policy" \
-H "Content-Type: application/json" -d '{
"policy": {
"description": "app log lifecycle",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [{ "state_name": "warm", "conditions": { "min_index_age": "7d" } }]
},
{
"name": "warm",
"actions": [{ "read_only": {} }],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "31d" } }]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
],
"ism_template": {
"index_patterns": ["logs-app-*"],
"priority": 100
}
}
}'
ism_template を含めておくと、パターンに一致するインデックスが新しく作られたときに自動でポリシーが適用される。
既存のインデックスには自動適用されない
ism_template が効くのは、ポリシー作成後に新しく作られたインデックスだけである。
13章ですでに作られた当日分のインデックスには適用されていないため、確認して手動で適用する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/_plugins/_ism/explain/logs-app-*?pretty"
"policy_id" : null であれば未適用なので、以下で適用する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X POST \
"https://127.0.0.1:9200/_plugins/_ism/add/logs-app-*" \
-H 'Content-Type: application/json' \
-d '{"policy_id": "logs-app-policy"}'
再度 _ism/explain を実行し、"policy_id" : "logs-app-policy" になっていることを確認する。
---
15. Dashboardsでログを確認する
ダッシュボードの作成は本記事の範囲外とし、ここではDiscover画面でログが検索できることだけを確認する。
SSHトンネルで接続する
Dashboardsは 127.0.0.1:5601 でのみ待ち受けているため、手元PCからSSHのポートフォワードで接続する。
【手元PC】
ssh -L 5601:localhost:5601 -p <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>
踏み台サーバーを経由する場合は -J を使う。
【手元PC】
ssh -L 5601:localhost:5601 -J <YOUR_USER>@<BASTION_IP>:<BASTION_SSH_PORT> -p <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>
接続したまま、手元PCのブラウザで http://localhost:5601 を開く。
https ではなく http である点に注意する。
ブラウザとDashboards間の通信はSSHトンネル自体で暗号化されているため、この構成では http のままで問題ない。
ログインには admin と、6章で設定したパスワードを使う。
SSHのセッションを閉じるとトンネルも切れるので、閲覧中はターミナルを開いたままにしておく。
初回ログイン時に Select your tenant という画面が表示された場合は、Global を選ぶ。
テナントは、Index Patternなどの保存オブジェクトを共有する範囲のことである。
Private を選んだまま次のIndex Patternを作成すると、作成したユーザー本人にしか見えず、16章で作る閲覧専用ユーザーからは見えなくなる。
現在のテナントは、右上のユーザーアイコンから確認、切り替えできる。
Index Patternを作成する
1. 左上のメニューから Dashboards Management を開く
2. Index Patterns を選び、Create index pattern をクリックする
3. パターンに logs-app-* と入力して Next step に進む
4. 時刻フィールドに @timestamp を選び、Create index pattern で完了する
Discoverで検索する
1. 左メニューから Discover を開き、logs-app-* を選ぶ
2. 画面右上で表示期間を指定する
3. 左のフィールド一覧から hostname、container_name、log_message を追加すると、その列だけを表示できる
4. 検索バーに hostname:app01 and container_name:testapp-web-1 のように入力すると絞り込める
:::note info
OpenSearch Dashboards 3.x系では、Discover画面が真っ白のまま何も表示されないことがある。
その場合は、Global テナントを選んだ状態で Dashboards Management の Advanced Settings を開き、query:enhancements:enabled を false に変更する。
Advanced Settingsはテナントごとに別管理であり、16章の閲覧専用ユーザーは自分で変更できないため、必ず Global 側で設定しておく。
参考: Discover page is blank due to query enhancements · Issue #10925
:::
---
16. 閲覧専用ユーザーを作成する
ログを見るだけの利用者に admin を渡すと、ユーザーやロールの変更、インデックスの削除まで行えてしまう。
そこで、DashboardsとDiscoverでログを閲覧できるが、ログやダッシュボードの書き込み、削除はできない閲覧専用ユーザー viewer を作成する。
閲覧専用ユーザーには、次の2つのロールを付与する。
| ロール | 種類 | 役割 |
| --- | --- | --- |
| logs_reader | 新規作成 | logs-* 系インデックスの読み取りと、Globalテナントの読み取りだけを許可する |
| kibana_user | 組み込み | Dashboardsへのログインと基本操作に必要。単体ではログデータへのアクセス権を持たない |
書き込みと削除を実際に防いでいるのは、logs_reader の権限である。
事前確認: マルチテナントが有効になっていること
7章の opensearch_dashboards.yml にマルチテナントの設定が入っていることを確認する。
【log-server】
grep -i "multitenancy\|requestHeaders" /opt/log-stack/opensearch_dashboards.yml
期待される結果: 7章で追加した3行が表示されること。
何も表示されない場合は7章の3行を追記し、docker compose restart dashboards で再起動する。
あわせて、admin でDashboardsにログインし、右上のユーザーアイコンに Switch tenants が表示されること、現在のテナントが Global であることを確認する。
15章で作成したIndex Patternと、query:enhancements:enabled の変更が Global テナント側にあることもここで確認しておく。
Private テナントで作成していた場合は、Dashboards Management の Saved objects でエクスポートし、Global に切り替えてからインポートする。
組み込みロールの名称の確認
Dashboards用の組み込みロールは、新名称(opensearch_dashboards_user)ではなく旧名称(kibana_user)で登録されていることがある。
実際の名称を確認してから進める。
【log-server】
curl -s -k -u 'admin:<ADMIN_PASSWORD>' \
"https://127.0.0.1:9200/_plugins/_security/api/roles" \
| python3 -c 'import json,sys; [print(k) for k in sorted(json.load(sys.stdin))]'
一覧に kibana_user があればそのまま進める。
kibana_user が無く opensearch_dashboards_user がある環境では、以降の kibana_user をそちらに読み替える。
読み取り用ロールの作成
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/roles/logs_reader" \
-H 'Content-Type: application/json' \
-d '{
"cluster_permissions": ["cluster_composite_ops_ro"],
"index_permissions": [
{
"index_patterns": ["logs-app-*", "logs-system-*", "logs-sysstat-*"],
"allowed_actions": ["read"]
}
],
"tenant_permissions": [
{
"tenant_patterns": ["global_tenant"],
"allowed_actions": ["kibana_all_read"]
}
]
}'
期待される結果: "status":"CREATED" を含むJSONが返ること。
| 区分 | 対象 | 権限 |
| --- | --- | --- |
| Cluster permissions | - | cluster_composite_ops_ro(検索系の読み取り操作のみ) |
| Index permissions | logs-app-*、logs-system-*、logs-sysstat-* | read(検索と取得のみ。書き込み、削除、インデックス作成は不可) |
| Tenant permissions | global_tenant | kibana_all_read(Globalテナントのダッシュボード等の閲覧のみ。保存と削除は不可) |
17章、18章を導入しない場合は、logs-system-* と logs-sysstat-* を外してもよい。
残しておいても害はない。
ユーザーの作成とロールの付与
ユーザー作成時に opendistro_security_roles を指定し、ユーザー側から直接ロールを付与する。
組み込みロールの rolesmapping を PUT すると、既存の紐付けを丸ごと置き換えてしまうためである。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \
"https://127.0.0.1:9200/_plugins/_security/api/internalusers/viewer" \
-H 'Content-Type: application/json' \
-d '{
"password": "<VIEWER_PASSWORD>",
"opendistro_security_roles": ["logs_reader", "kibana_user"],
"description": "Read-only viewer"
}'
curl -k -u 'admin:<ADMIN_PASSWORD>' -X DELETE \
https://127.0.0.1:9200/_plugins/_security/api/cache
期待される結果: 1つ目で "status":"CREATED"、2つ目で "status":"OK" が返ること。
:::note warn
キー名は opendistro_security_roles である。
opensearch_security_roles のように新しい名称に見える綴りにすると、ロールが付与されない。
:::
この方式では、DashboardsのSecurity画面にある Roles の Mapped users タブに viewer は表示されない。
付与状況は次の動作確認のAPIで確認する。
動作確認
ロールが付与されていることを確認する。
【log-server】
curl -k -u 'viewer:<VIEWER_PASSWORD>' \
https://127.0.0.1:9200/_plugins/_security/api/account
期待される結果: "roles" に logs_reader と kibana_user が含まれること。
ログインデックスへの書き込みと削除が拒否されることを確認する。
【log-server】
# 書き込み(拒否されるべき)
curl -k -u 'viewer:<VIEWER_PASSWORD>' -X POST \
"https://127.0.0.1:9200/logs-app-test/_doc" \
-H 'Content-Type: application/json' -d '{"message": "should fail"}'
# インデックスの削除(拒否されるべき)
curl -k -u 'viewer:<VIEWER_PASSWORD>' -X DELETE \
"https://127.0.0.1:9200/logs-app-$(date +%Y.%m.%d)"
期待される結果: どちらも 403 と security_exception が返ること。
Globalテナントへの保存が拒否されることを、DashboardsのAPIで確認する。
ここではテスト用のIndex Patternを作成しようとして、拒否されることを見る。
【log-server】
curl -s -u 'viewer:<VIEWER_PASSWORD>' -X POST \
-H 'securitytenant: global' \
-H 'osd-xsrf: true' \
-H 'Content-Type: application/json' \
"http://127.0.0.1:5601/api/saved_objects/index-pattern/viewer-write-test" \
-d '{"attributes": {"title": "viewer-write-test-*"}}'
期待される結果: 403 のエラーが返ること。
"id":"viewer-write-test" を含むJSONが返った場合は保存できてしまっているので、logs_reader のTenant permissionsとマルチテナントの設定を見直し、作成されたオブジェクトを admin で削除する。
最後にDashboardsの画面で確認する。
1. 15章と同じ手順でSSHトンネルを張り、一度ログアウトしてから viewer でログインする
2. テナントの選択画面が表示されたら Global を選ぶ
3. 左メニューに Discover が表示され、logs-app-* のログが閲覧できること(真っ白にならないこと)
4. 左メニューに Security が表示されないこと
設計上の注意点
- 画面上には Edit や Save のボタンが表示されるが、押してもGlobalテナントへの保存は 403 で拒否され、反映されない。見た目は編集できそうに見えても、実際には何も変更できない状態である。
- Dashboardsの読み取り専用モード(opensearch_security.readonly_mode.roles)は使わない。このモードは編集ボタンを隠すだけでなく、Discoverなどダッシュボード以外のアプリもメニューから隠してしまうため、Discoverでログを見るという目的を満たせない。
- viewer が閲覧できるのはGlobalテナントの保存オブジェクトだけである。今後 admin でIndex Patternやダッシュボードを作る際は、必ず Global テナントを選んだ状態で作成する。
- viewer 自身の Private テナントへの保存はできるが、他のユーザーやGlobalテナント、ログデータには影響しない。
- admin は all_access のままなので、閲覧や操作の範囲は変わらない。マルチテナントの有効化により、ログイン時にテナントを選ぶ手順が加わる点だけが変わる。
ここまでで、ログの収集と閲覧の基盤は完成である。
以降の2章は、必要に応じて追加する任意の手順である。
---
17. (任意)システムログ全体を収集する
12章の設定は、myapp@*.service のログだけを収集する。
カーネルログや他のサービスのログなど、OS全体のジャーナルも収集したい場合は、別のパイプラインとして追加する。
既存のパイプラインに混ぜないのは、ログ量と保持期間の要件が異なるためである。
アプリケーションログは障害調査で頻繁に参照するが、OS全体のログは量が多く、参照頻度は低い。
そこで別インデックス logs-system-* に格納し、保持期間も短くする。
fluentd.confへの追記
既存のブロックは変更せず、末尾に追記する。
grep フィルタは入れず、全件を対象にする。
バッファの格納先も別ディレクトリにする。
【app01 / app02 / app03(それぞれで実行)】
sudo tee -a /etc/fluent/fluentd.conf > /dev/null <<'EOF'
<source>
@type systemd
path /var/log/journal
read_from_head false
tag system.journal
<storage>
@type local
persistent true
path /var/log/fluent/systemd-system.pos
</storage>
<entry>
fields_strip_underscores true
</entry>
</source>
<filter system.journal>
@type record_transformer
enable_ruby true
<record>
"@timestamp" ${Time.at(time.to_i).strftime('%Y-%m-%dT%H:%M:%S%:z')}
hostname ${Socket.gethostname}
</record>
</filter>
<match system.journal>
@type opensearch
host <LOG_SERVER_IP>
port 9200
scheme https
ssl_verify true
ca_file /etc/fluent/certs/root-ca.pem
user fluentd
password "<FLUENTD_PASSWORD>"
index_name logs-system-%Y.%m.%d
logstash_format false
reconnect_on_error true
reload_on_failure true
<buffer time>
@type file
timekey 1d
flush_mode interval
path /var/log/fluent/buffer/opensearch-system
flush_interval 5s
retry_max_times 10
retry_wait 5s
chunk_limit_size 8MB
</buffer>
</match>
EOF
sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf
sudo systemctl restart fluentd
書き込み権限は9章のロールですでに許可済みである。
ISMポリシー
warmを挟まず、7日で削除する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-system-policy" \
-H "Content-Type: application/json" -d '{
"policy": {
"description": "system journal log lifecycle",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "7d" } }]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
],
"ism_template": {
"index_patterns": ["logs-system-*"],
"priority": 100
}
}
}'
既存インデックスへの適用確認は14章と同じ手順で行う。
Dashboardsで閲覧する場合は、15章と同じ要領で logs-system-* のIndex Patternを作成する。
:::note info
全ジャーナルを対象にするとログ量が大きく増える。
いきなり全台に入れるのではなく、1台で1〜2週間ほど試験運用し、ディスク使用量の増え方を見てから広げると安全である。
:::
---
18. (任意)sysstatのリソース使用状況を取り込む
CPU、メモリ、ディスクI/Oの使用状況を、ログと同じOpenSearch上で確認できるようにする。
sysstatは常時ログを出力するサービスではなく、sar で問い合わせるとその時点の値を返すツールである。
そこで、Fluentdの exec 入力プラグインで1分おきにスクリプトを実行し、結果を取り込む。
sysstatの導入
【app01 / app02 / app03(それぞれで実行)】
sudo apt update
sudo apt install -y sysstat
sudo sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat
sudo systemctl enable --now sysstat
sar -u 1 1
期待される結果: CPU使用率のレポートが表示されること。
出力形式の確認
sadf -j(JSON出力)のキー名は、sysstatのバージョンによって異なることがある。
スクリプトを作る前に、自分の環境での出力を確認しておく。
LC_ALL=C sadf -j -- -u -r -d 1 1
LC_ALL=C は必ず付ける。
ロケールによっては小数点が , で出力され、JSONとして不正な形式になる既知の不具合があるためである。
参考: sadf produces invalid JSON output: Floats with comma instead of dot · Issue #128
出力の中に cpu-load、memory、disk のキーがあることを確認する。
Ubuntu 20.04と22.04では、cpu-load の user system idle と、memory の memused-percent memused は共通だった。
一方、disk のデバイス名は、22.04では vda のような名前、20.04では dev252-0 のような番号形式になることがある。
次のスクリプトは disk の配列をそのまま格納するため影響はないが、デバイス名で絞り込む際は注意する。
変換スクリプトの作成
sadf -j の出力は複数行にまたがる整形済みJSONであり、Fluentdの行単位の読み取りと相性が悪い。
そこで、必要な値だけを抜き出して1行のJSONにまとめるスクリプトを用意する。
また、sar は2回サンプリングして初めてその間の差分を計算できる。
1回だけのサンプリングでは値がほぼ空になるため、sar -o で2回分を一時ファイルに記録してから sadf で読む。
【app01 / app02 / app03(それぞれで実行)】
sudo mkdir -p /opt/fluent-scripts
sudo tee /opt/fluent-scripts/sysstat_collect.py > /dev/null <<'EOF'
#!/usr/bin/env python3
import subprocess
import json
import socket
import datetime
import os
import tempfile
env = os.environ.copy()
env["LC_ALL"] = "C"
with tempfile.NamedTemporaryFile(prefix="sysstat_", delete=True) as tmp:
# 1秒間隔で2回、一時ファイルにライブ測定を記録する
subprocess.check_call(
["sar", "-o", tmp.name, "1", "2"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
raw = subprocess.check_output(
["sadf", "-j", "--", "-u", "-r", "-d", tmp.name],
text=True,
env=env,
)
data = json.loads(raw)
# 最後の要素が実際の差分データ
stat = data["sysstat"]["hosts"][0]["statistics"][-1]
# キー名は「出力形式の確認」の結果に合わせて調整する
cpu = stat.get("cpu-load", [{}])[0]
mem = stat.get("memory", {})
disks = stat.get("disk", [])
record = {
"@timestamp": datetime.datetime.now().astimezone().isoformat(timespec="seconds"),
"hostname": socket.gethostname(),
"cpu_user": cpu.get("user"),
"cpu_system": cpu.get("system"),
"cpu_idle": cpu.get("idle"),
"mem_used_percent": mem.get("memused-percent"),
"mem_used_kb": mem.get("memused"),
"disks": disks,
}
print(json.dumps(record, ensure_ascii=False))
EOF
sudo chmod 755 /opt/fluent-scripts/sysstat_collect.py
Fluentdの実行ユーザー _fluentd で正しく動くか確認する。
sudo runuser -u _fluentd -- /opt/fluent-scripts/sysstat_collect.py
期待される結果: {"@timestamp": "...", "hostname": "...", "cpu_user": ..., ...} のような改行を含まない1行のJSONが出力されること。
何度か実行して cpu_user などの値が変動していれば、ライブ測定が正しく行われている。
:::note warn
スクリプトをホームディレクトリ配下(例: /home/<YOUR_USER>/scripts/)に置く場合は注意が必要である。
Ubuntuのホームディレクトリは 750 になっていることが多く、_fluentd はスクリプト自体の権限が正しくても手前のディレクトリを通過できず Permission denied になる。
本記事で /opt/fluent-scripts に置いているのはこのためである。
ホームディレクトリに置く必要がある場合は、sudo chmod o+x /home/<YOUR_USER> で通過権限だけを追加する。
:::
fluentd.confへの追記
【app01 / app02 / app03(それぞれで実行)】
sudo tee -a /etc/fluent/fluentd.conf > /dev/null <<'EOF'
<source>
@type exec
tag sysstat.metrics
command /opt/fluent-scripts/sysstat_collect.py
run_interval 60s
<parse>
@type json
</parse>
</source>
<match sysstat.metrics>
@type opensearch
host <LOG_SERVER_IP>
port 9200
scheme https
ssl_verify true
ca_file /etc/fluent/certs/root-ca.pem
user fluentd
password "<FLUENTD_PASSWORD>"
index_name logs-sysstat-%Y.%m.%d
logstash_format false
reconnect_on_error true
reload_on_failure true
<buffer time>
@type file
timekey 1d
flush_mode interval
path /var/log/fluent/buffer/opensearch-sysstat
flush_interval 5s
retry_max_times 10
retry_wait 5s
chunk_limit_size 8MB
</buffer>
</match>
EOF
sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf
sudo systemctl restart fluentd
run_interval 60s により、1分おきにスクリプトが実行される。
書き込み権限は9章のロールですでに許可済みである。
ISMポリシー
1分おきに継続してデータが増えるため、14日で削除する。
【log-server】
curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-sysstat-policy" \
-H "Content-Type: application/json" -d '{
"policy": {
"description": "sysstat metrics lifecycle",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "14d" } }]
},
{
"name": "delete",
"actions": [{ "delete": {} }]
}
],
"ism_template": {
"index_patterns": ["logs-sysstat-*"],
"priority": 100
}
}
}'
既存インデックスへの適用確認は14章と同じ手順で行う。
---
19. 構築後の確認項目
| 確認内容 | 確認方法 |
| --- | --- |
| OpenSearchとDashboardsが起動している | 【log-server】docker compose ps で両方が Up |
| 9200番が送信元IP以外から遮断されている | 【log-server】sudo iptables -L DOCKER-USER -n -v に DROP の行がある |
| 第三者のIPから9200番に接続できない | 送信元以外の回線から curl -k https://<LOG_SERVER_IP>:9200 がタイムアウトする |
| 各送信元でFluentdが稼働している | 【各送信元】systemctl status fluentd がエラーなく active |
| 全サーバー分のログが届いている | Discoverで hostname ごとに件数がある |
| 閲覧専用ユーザーで検索だけができる | 【log-server】viewer で _search が成功し、_doc への書き込みが 403 になる |
| ISMポリシーが適用されている | 【log-server】_plugins/_ism/explain/logs-* で policy_id が入っている |
---
運用上の注意点
- Docker公開ポートのアクセス制御は、ufwではなくDOCKER-USERチェーンで行う。Dockerの更新やサーバー再起動のあとは iptables -L DOCKER-USER -n -v で再確認する。
- 送信元サーバーのグローバルIPが変わると、ログが届かなくなる。サーバーを追加、移設した際は /etc/ufw/after.rules の更新を忘れないようにする。
- 証明書の有効期限(本記事では3650日)を管理台帳などに記録しておく。
- root-ca-key.pem を持っていれば任意の証明書を発行できてしまう。ログ集約サーバーの外には出さず、バックアップも厳重に扱う。
- kibanaserver のパスワードは opensearch_dashboards.yml に平文で書かれる。/opt/log-stack のディレクトリの権限は、管理者以外が読めないようにしておく。
- ログを閲覧するだけの利用者には admin を渡さず、16章の viewer のような閲覧専用ユーザーを使う。利用者ごとにユーザーを分けておくと、退職や異動の際にそのユーザーだけを削除できる。
- セキュリティ設定は初回起動時にしか internal_users.yml から初期化されない。運用開始後のユーザー追加やパスワード変更は、REST APIかDashboardsの Security 画面から行う。
- DashboardsのGUIでロールのIndex permissionsを編集する場合、Document level securityの欄を空文字列のまま確定しないこと。空文字列が保存されると、そのインデックスパターンへの権限判定全体が無効になる現象を3.8.0で確認している。誤って保存した場合は、API(GET _plugins/_security/api/roles/<ロール名>)で定義を確認し、dls キーを含まない形でPUTし直す。
参考文献
| 項目 | 参照先 |
| --- | --- |
| Docker公式インストール手順 | docs.docker.com |
| DockerがUFWをバイパスする問題 | for-linux#777 |
| DOCKER-USERチェーンによる対策 | chaifeng/ufw-docker |
| OpenSearchの重要なシステム設定 | docs.opensearch.org |
| OpenSearchのDocker構築 | docs.opensearch.org |
| 自己署名証明書の生成 | docs.opensearch.org |
| Security設定全般 | docs.opensearch.org |
| internal_users.ymlとhash.sh | docs.opensearch.org |
| デモ設定インストーラーがスキップされる件 | forum.opensearch.org |
| restapi.roles_enabledが必要な件 | forum.opensearch.org |
| ISM API | docs.opensearch.org |
| fluent-packageのインストール | docs.fluentd.org |
| fluent-plugin-systemd | fluent-plugins-nursery/fluent-plugin-systemd |
| fluent-plugin-opensearch | fluent/fluent-plugin-opensearch |
| sadfのJSON小数点の不具合 | sysstat#128 |
| Discover画面が真っ白になる件 | OpenSearch-Dashboards#10925 |
Which data sources would you like to see supported in #OpenSearch Dashboards?
Anything you'd like to see added, besides the recently-added @PrometheusIO? chime in with your comments here or on the GitHub issue
OpenSearch-Dashboards#12819
#OpenSearchAmbassador #observability t.co
37 views
Which data sources would you like to see supported in #OpenSearch Dashboards?
Anything you'd like to see added, besides the recently-added @prometheus.io ? chime in with your comments here or on the GitHub issue
OpenSearch-Dashboards#12819
#OpenSearchAmbassador #dashboards #observability
Opensearch scalability is a big challenge which we have been facing as well.
Our team is exploring cloud native architecture which has been nicely captured in this RFC (OpenSearch#17957). Will be a good weekend read, will share my learnings.
4 likes · 169 views
Mentions
[RFC] Cluster Best Practices: An automated advisor for proper OpenSearch usage · Issue #23468 (#OpenSearch community, your input and insight would be greatly appreciated) #opensource #OpenSearchAmbassador OpenSearch#23468
[RFC] Cluster Best Practices: An automated advisor for proper OpenSearch usage · Issue #23468 (#OpenSearch community, your input and insight would be greatly appreciated) #opensource #OpenSearchAmbassador OpenSearch#23468
Lucene 10.6.0 の doc-values updates は列ごとの書き直しをどうやめたのか はじめに OpenSearch や Elasticsearch では、検索したいデータを JSON のドキュメント(doc)として index に保存します。 doc は title や price のような field の集まりで、OpenSearch や Elasticsearch はこの field の値を使って検索、ソート、集計をします。 この index を実際にディスクに書き、検索するのが、Java で書かれた検索ライブラリの Apache Lucene です。 Lucene は index を segment という単位のファイル群に分けて書き、一度書いた segment は変更しません。 こうして保存する doc には、一部の field だけが頻繁に変わるものがよくあります。 EC の在庫数や価格、記事の閲覧数、ランキング用の人気スコアなどです。 doc 本体には本文や 512 次元の vector が入っていて、変わるのは 8 バイトの数値 1 つだけ、という形です。 新しい doc は新しい segment に書かれ、既存の segment のファイルはそのまま残ります。 Lucene でこの数値を変える方法は 2 つあります。 1 つは IndexWriter#updateDocument で doc ごと入れ直す方法で、本文も vector も index し直すことになります。 もう 1 つは IndexWriter#updateNumericDocValue で、doc-values の値だけを書き換える方法です。 doc-values は、Lucene が field の値を doc ID 順に並べて列として持つ構造です。 全文検索に使う転置インデックスは「語から doc」を引く向きの構造で、doc-values はその逆の「doc から値」を引く向きです。 ソート、ファセット、集計は、この列を doc ID で引いて値を読みます。 OpenSearch や Elasticsearch の mapping にある doc_values は、この列を作るかどうかの設定で、text 以外の多くの field では default で有効です。 updateNumericDocValue は doc を入れ直さないので、軽い操作に見えます。 ところが Lucene 10.5 までの doc-values update は、別のものを丸ごと書き直していました。 更新した doc の数に関係なく、その field の列(column)を segment 1 つぶん書き直すのです。 2026 年 10 月 29 日にリリースされた Apache Lucene 10.6.0 で、この書き直しをやめる仕組みが入りました。 CHANGES.txt の New Features には次のように書かれています。 GITHUB#16418: Incremental doc-values updates. A set-only doc-values update (NUMERIC or BINARY) is written as a sparse "delta" overlay holding just the updated documents and layered over the base column at read time, instead of rewriting the whole column, turning per-update write amplification from O(column) into O(updated docs). Opt-in; enable with IndexWriterConfig#setMaxDocValuesOverlays(n). 気になるのは、書き込み量がどれだけ減り、代わりに何を払うのか、そして OpenSearch と Elasticsearch にどう関わるのかです。 元になった Pull Request #16418 と、10.5.0 と 10.6.0 のソースを読んで確かめました。 ! 本文のクラス名とメソッド名は Lucene 10.5.0 と 10.6.0 のタグのものです。 issue や PR の状態は 2026 年 11 月 1 日時点で確認しました。 setMaxDocValuesOverlays には @lucene.experimental が付いており、今後挙動が変わる可能性があります。 1 doc を更新すると何が書かれるのか gen ファイルに書かれる列 doc-values の列も segment の一部なので、segment と同じく一度書いたら変更しません。 値を更新するには、別のファイルを書き足す必要があります。 Lucene はこの書き足しを generation(gen)という番号で管理しています。 doc-values を更新すると、その field について新しい gen のファイルを書き、読み取り時には最新の gen を使う仕組みです。 10.5.0 の ReadersAndUpdates#handleDVUpdates では、次の gen 番号を 36 進数の文字列にしてファイル名の suffix にしています。1 final long nextDocValuesGen = info.getNextDocValuesGen(); final String segmentSuffix = Long.toString(nextDocValuesGen, Character.MAX_RADIX); 問題は新しい gen ファイルの中身です。 同じメソッドの中で、MergedDocValues が既存の列の値と更新値を doc ID 順に混ぜ合わせ、その結果を新しい gen として書き出します。 更新されなかった doc の値も含めて、列全体です。 新しい gen ファイルの大きさは列全体(値を持つすべての doc)で決まり、更新した doc が 1 件でも 3 件でも同じです。 PR の説明はこの状態を「Lucene rewrites the whole column for that field, even if you only touched a few docs」と書いています。 segment が大きくなるほど、1 回の更新で書く量も増えます。 soft delete の場合 この書き直しは明示的に doc-values を更新したときだけの話ではありません。 Lucene の soft delete は、削除の印を soft-deletes 用の numeric doc-values field に書くことで実現しています。 つまり soft delete も doc-values update であり、同じ経路で列を書き直します。 soft-deletes の列は印を付けた doc だけを持つので、1 回に書く量は数値 field の列ほど大きくありません。 Elasticsearch は操作の履歴を soft delete した doc として Lucene に残しています。 shard の復旧や CCR(cross-cluster replication)で、この履歴を使うためです。 後で見る Elasticsearch の PR #157749 の説明にも、この仕組みが出てきます。 OpenSearch は、この仕組みを持つ Elasticsearch 7.10 から分岐したプロジェクトです。 そのため、doc-values を明示的に更新していない index でも、update や delete で doc を置き換えるたびにこの経路を通ります。 10.6.0 で入った overlay 読み取り時の重ね方 PR の説明によると、10.6.0 で入った仕組みの考え方は単純です。 値を設定するだけの更新(set-only)なら、変わった doc だけを持つ疎な(sparse)差分を新しい gen として書きます。 PR ではこの差分を delta、読み取り時に重ねる層を overlay と呼んでいます。 先ほどの図と同じ更新でも、書くのは変わった doc の数だけになります。 読み取り時は base の列の上に delta を新しい順に重ね、同じ doc が複数の層にあれば最新の値を使う、という読み方です。 PR の説明はこれで「Updating a field becomes O(changed docs) instead of O(column)」になると書いています。 読み取り側は新しく入った OverlayNumericDocValues と OverlayBinaryDocValues(10.6.0 では OverlaySortedNumericDocValues も)が担当します。 delta は codec に元からある疎な形式で書くので、PR は「No doc-values format change」と書いています。 overlay が溜まったときの fold delta を書き足すだけだと、層がどんどん積み上がり、読み取りが遅くなります。 そこで field ごと、segment ごとに、次の決まりで delta を畳みます(fold)。 flush のたびに、変わった doc だけの delta を 1 つ書く delta の数が上限に達していたら、次の更新を書くときに既存の delta と今回の更新を 1 つにまとめる fold のとき、既存の delta が持つ doc 数の合計が segment の doc 数の 50% 以上なら、base と合わせて新しい密な(dense)base を書く segment の merge で、普通の列に戻る 上限の数は後で見る setMaxDocValuesOverlays で指定します。 最初の実装では、dense な base に戻すのは delta が列全体を覆ったときだけでした。 review で neoremind が bug を見つけます。 一度 dense な base に fold すると、それ以降の fold がすべて列全体の書き直しに戻っていました。 作者は判定を segment に対する割合に変え、閾値を 50% にして直しています。 overlay にならない更新 すべての更新が delta になるわけではありません。 PR #16418 の時点の対象は NUMERIC と BINARY の doc-values でした。 10.6.0 では #16581 で SORTED_NUMERIC も単一の値で更新できるようになりました。 この更新は、列が単一値か複数値かに関係なく同じ delta の経路を通ります(複数値の doc は読み取り時に doc 単位で保たれます)。2 OpenSearch や Elasticsearch の数値 field は SORTED_NUMERIC の列として書かれるので、この拡張も関係します。 どの型でも対象は値を設定するだけの更新に限られ、値を消す(unset する)更新が混ざると、従来どおり列全体を書き直します。 ベンチマークで見る効果と代償 更新の間隔による書き込み量の違い PR #16418 には、作者自身のベンチマークが載っています。 対象は 8 バイトの数値と 512 次元の vector を持つ 5M(500 万)doc です。 4 スレッドがランダムな順序で更新し、NRT reader を 1 秒ごとに refresh します。 比べている方法は 3 つです。 full reindex は updateDocument で doc ごと入れ直す方法、dense は 10.5 までの doc-values update、sparse は今回の overlay です。 作者は、書き込み量が更新の間隔で大きく変わると考え、全速で更新する batch と、毎秒 5,000 件に絞った throttled の 2 通りで測っています。 数値 field の結果です。3 条件 方法 bytes/update 書き込みの倍率 batch full reindex 13,138 1,642× batch dense 80 10× batch sparse 8 1.0× throttled 5k/s full reindex 14,359 1,795× throttled 5k/s dense 7,202 900× throttled 5k/s sparse 18 2.2× 倍率は書いたバイト数を実際に変わった 8 バイトで割った値です。 batch では dense でも 10× で済んでいます。 refresh 1 回のあいだに大量の doc が更新されるので、列全体の書き直し 1 回を多くの doc で分け合えるからです。 throttled にすると、同じ書き直しが数千件ぶんしかカバーしなくなり、900× に跳ね上がります。 sparse は間隔に関係なく変わった doc だけを書くので、どちらでも 1.0〜2.2× に収まります。 throttled の dense と sparse を比べると、書き込み量は約 400 分の 1 です。 throttled の行では、dense と full reindex の差も小さくなっています。 doc ごと入れ直す 14,359 バイトに対して、doc-values update は 7,202 バイトで、半分にしかなっていません。 32 バイトの binary field では、さらに逆転します。 条件 方法 bytes/update throttled 5k/s full reindex 15,756 throttled 5k/s dense 27,734 throttled 5k/s sparse 55 このベンチマークの binary field では、更新が少しずつ流れ続けると、10.5 までの doc-values update が doc ごと入れ直すより多く書いていたことになります。 作者は throttled を「closer to a steady trickle on a live index」と位置づけていて、運用中の index に近いのはこちらの条件です。 soft delete でも同じ傾向が出ていて、throttled では dense の 33 bytes/update が sparse で 6 bytes/update になっています。 読み取りの遅れ 書き込みが減るぶん、読み取りは遅くなります。 読み取りのたびに base と delta を重ね合わせるからです。 同じベンチマークで、500 万 doc の field を頭から読む scan の時間はこう変わりました。 field 条件 dense sparse 数値 batch 35 ms 118 ms 数値 throttled 5k/s 21 ms 103 ms binary 32B batch 29 ms 327 ms binary 32B throttled 5k/s 28 ms 172 ms scan は 3〜11 倍遅くなっています。 作者はソートやファセットのように doc を飛び飛びに引く読み方も測っています。 advanceExact で 10% の doc を引く測定では、overlay が 16 層のとき約 18 ms で、通常の列は約 1 ms でした。4 throttled で約 350 の gen が残った状態では、約 26 ms 対約 7 ms です。 作者はこれを「tens of ns per lookup」と書き、クエリ全体の時間と比べれば小さいとしています。 ただし、どちらの数値も作者が PR の review のために書いたベンチマークのものです。 benchmark のコードは merge 前に外すと書かれていましたが、2026 年 11 月 2 日時点では main のテストソースに、単独で実行する IncrementalDocValuesUpdatesBenchmark として残っています。 luceneutil や nightly benchmark のような第三者の測定は見つかりませんでした。 自分の index で書き込みの減少が読み取りの遅れに見合うかは、更新の間隔と読み取りの重さを比べて決めることになります。 overlay を有効にする設定 overlay は IndexWriterConfig#setMaxDocValuesOverlays(int) で有効にします。 0 は無効で、従来どおり列全体を書き直す設定です。 1 以上の値を渡すと有効になり、その数まで overlay を溜めてから fold します。 10.6.0 の Javadoc はこの値のトレードオフを次のように書いています。5 higher values lower write amplification but keep more overlays (hence more files) live to merge at read time. 10.6.0 の default は 0 で、この機能は opt-in です。 setMaxDocValuesOverlays の Javadoc には「Only takes effect when IndexWriter is first created」ともあり、6動いている IndexWriter に後から設定しても効きません。 ! 10.6.0 のリリースノートの Highlights には「This is on by default」と書かれていますが、10.6.0 のソースの default は 0 です。 backport の PR #16554 も「opt-in on 10.x」と明記しています。 on by default なのは 11.0 系(branch_11x)と main(12.0)で、default は #16648 で 16 から 8 に下がっています。 アップグレード後に古い Lucene で開けるか overlay を有効にしなくても、10.6.0 で変わることが 1 つあります。 segments ファイルの format version が上がりました。7 /** The version that records per-field incremental doc-values overlay generations. */ public static final int VERSION_10_6 = 11; static final int VERSION_CURRENT = VERSION_10_6; 古い Lucene は overlay を読めないので、delta を列全体と取り違えないよう、format version で弾く必要があります。 PR #16554 はこの version を機能を使うかどうかに関係なくすべての commit で書くと説明しています。 So any index written by 10.6+ is rejected by older 10.x readers with IndexFormatTooNewException, whether or not it uses the feature. Lucene は新しいバージョンで書いた index を古いバージョンで読めることを元から約束していません。 そのため規約の上では何も失われていない、というのが PR の立場です。 運用側から見ると、10.6.0 で一度でも commit した index は、10.5 系の Lucene に戻した時点で開けなくなります。 ローリングアップグレードの途中で旧版に戻す手順を用意しているなら、10.6.0 からは使えないと考えたほうが安全です。 review では ChrisHegarty が、commit 時に dense へ fold すれば古いバージョンでも読めるのでは、と提案しています。 作者は、refresh と commit を同じ頻度で行う使い方ではcommit 時に fold する案ではこの機能の効果が消えるとして、採用しませんでした。 同じリリースの .dvp 分離 10.6.0 では doc-values のファイル構成も別の変更で変わっています。 PR #16540(issue #16298)は、疎な field の doc ID 集合(IndexedDISI)を .dvd から新しい .dvp ファイルに分けました。 overlay とは独立した変更ですが、どちらも「doc-values まわりのファイルが変わった」と読めるので、混同しないよう気をつけてください。 Lucene90DocValuesConsumer や Lucene90DocValuesProducer のコンストラクタを直接呼んでいるコードは、こちらの変更に合わせて直す必要があります。 OpenSearch の star-tree がその例です。 OpenSearch と Elasticsearch の対応状況 OpenSearch OpenSearch は PR #23475 で Lucene を 10.5.2 から 10.6.0 に上げ、2026 年 10 月 31 日に main へ merge しました。 Version.V_3_10_0 が LUCENE_10_6_0 を指すようになっています。 PR で直したコードの 1 つは、前の節で触れた .dvp 分離に合わせた star-tree の doc-values の対応です。 OpenSearch は overlay の設定には手を入れていません。 PR の diff にも、GitHub のコード検索で見た OpenSearch のリポジトリにも setMaxDocValuesOverlays は出てきません。 Lucene 10.6.0 は default の 0、つまり overlay が無効のまま取り込まれています。 一方、segments format の変化は、機能の有無に関係なく OpenSearch にも及びます。 PR には書かれていませんが、Lucene 側の仕様から考えると、OpenSearch 3.10 で書いた index は Lucene 10.5 系の OpenSearch では開けなくなるはずです。 Elasticsearch Elasticsearch には、10.6.0 への upgrade PR は見つかりませんでした。 Elasticsearch は lucene_snapshot というブランチで Lucene の開発版に追従しているので、公開リポジトリ上の取り込み方が OpenSearch とは違います。 代わりに、overlay を使う機能の提案があります。 #16418 の作者である jimczi による PR #157749「Add updatable doc-values fields」で、2026 年 11 月 1 日時点で open のままです。 feature flag doc_values_updatable の内側にあり、ラベルは v9.7.0 です。 この PR は mapping に doc_values: { updatable: true } を足し、_update や _bulk の部分更新を doc の入れ直しではなく doc-values の書き換えで行います。 対象は keyword、数値、date の field で、index: false であること、index sort に含まれないこと、sub-field を持たないことが条件です。 PR が「columnar indices only」と書く種類の index で、新しい index version(IndexVersions.DOC_VALUES_UPDATABLE)以降に作ったものに限られます。 PR はねらいを「avoiding the cost of rewriting large immutable fields such as vectors」と書いていて、はじめにで挙げた「vector を持つ doc の数値 1 つだけを変える」場面そのものです。 overlay の上限は index 設定 index.doc_values.max_overlays で決めます。 doc-values の書き換えだけで済ませるには、Elasticsearch 側にも工夫が要ります。 _seq_no は in-place で書き換えられないので、更新の sequence number を _dv_update_seq_no という別の列に記録しています。 また、更新の差分だけを replica に送っても、replica が base の doc を持っていなければ意味がありません。 そのため、base の doc が global checkpoint 以下(すべての in-sync なコピーが持っていることが保証される範囲)にあるときだけ差分を送り、それ以外は従来の reindex に戻しています。 関連する変更と残っている課題 #16418 の後にも、いくつかの変更と議論が続いています。 ファイル数の問題は issue #16644 で dweiss が報告しました。 テストがファイルハンドルの上限に引っかかったというもので、issue に引用された分析は、開いているファイルの数が「segments × 更新した field 数 × (1 + overlay 数) × 2」に比例して増えるという見積もりを書いています(原文は英語)。 作者の jimczi は、増え方は field ごとに overlay の上限で抑えられると答えています。 issue はテスト側で overlay 数をランダムにする #16645 で閉じられ、これとは別に #16648 で default も 16 から 8 に下がりました。 overlay を有効にするなら、OS のファイルハンドル上限も合わせて確認しておくと安全です。 merge 中の更新の扱いも変わりました。 10.5 までは、merge 中に来た doc-values update を ReadersAndUpdates が heap に溜めていました。 #16570 はこれをやめ、merge の完了時に元の segment から読み直すようにしています。 10.x への backport(#16591)では、この挙動は overlay を有効にしたときだけ働きます。 10.6.0 には、SORTED_NUMERIC の更新のほかにも関連する変更が入っています。 #16565 は index sort に使う field への binary update を拒否するようにしました。 PR #16418 の review で後回しにされた項目と、10.6.0 のソースに TODO として残っている項目もあります。 reader を開くときに delta を 1 つにまとめる。shaie の提案に、作者は「it's the top follow-up」と答えています8 読み取りで intoBitSet を使う、dense な base を直接読んで変わった doc だけ差し替える9 field ごとに設定を変えられるようにする。ChrisHegarty の提案で、作者は「Good follow-up」と答えています10 毎回すべての delta を畳み直すのをやめ、size-tiered な fold にする11 fold の閾値 50% を設定で変えられるようにする12 確認できていないこと dev メーリングリスト13に、この機能を扱った専用のスレッドは見つかりませんでした。確認できたのはリリースの告知だけです。 ベンチマークの数値は作者のものだけで、第三者の測定は見つかっていません。 overlay を有効にしたときに、実際の index でファイルハンドルがどれだけ増えるかは測っていません。 Elasticsearch が Lucene 10.6.0 をどのバージョンで取り込むかは確認できていません。 おわりに はじめにの在庫数の例に戻ります。 10.5 までの updateNumericDocValue は在庫数を数件変えただけでも、refresh のたびに segment ぶんの在庫数の列を書き直していました。 10.6.0 で overlay を有効にすれば、書くのは変わった在庫数だけになります。 その代わり、在庫数でソートや集計をするたびに、base に delta を重ねる手間がかかります。 10.6.0 では opt-in なので、何もしなければこの挙動は変わりません。 ただし segments format は必ず上がるので、10.6.0 で一度 commit した index は 10.5 では開けなくなります。 Lucene を直接使っているなら、まず自分の index の更新の間隔と、ソートやファセットの重さを見比べてみてください。 更新が少しずつ続き、読み取りが軽いなら、setMaxDocValuesOverlays に小さな値を渡して試す価値があります。 参考リンク Release 10.6.0 · apache/lucene Lucene Core News CHANGES.txt(10.6.0) Incremental doc-values updates · PR #16418 Backport incremental doc-values updates to 10.x · PR #16554 Incremental doc values increase open file handle count significantly · Issue #16644 Upgrade Lucene to 10.6.0 · OpenSearch PR #23475 Add updatable doc-values fields · Elasticsearch PR #157749 脚注 ReadersAndUpdates.java#L323-L324(10.5.0) ↩︎ OverlaySortedNumericDocValues.java(10.6.0) の Javadoc。CHANGES.txt の GITHUB#16580 は「Single-valued columns reuse the numeric update path」と単一値の列に限った書き方をしていますが、コードは複数値の列にも delta を書きます。 ↩︎ PR #16418 の Numbers 節。この節の表と数値は、特記しない限りここから引いています。 ↩︎ PR #16418 の作者のコメント。「tens of ns per lookup」もこのコメントからの引用です。 ↩︎ IndexWriterConfig.java#L111-L116(10.6.0) ↩︎ IndexWriterConfig.java#L336-L348(10.6.0) ↩︎ SegmentInfos.java#L129-L132(10.6.0) ↩︎ 作者の回答 ↩︎ 作者のコメント ↩︎ ChrisHegarty の提案、作者の回答 ↩︎ ReadersAndUpdates.java#L390-L391(10.6.0) の TODO ↩︎ ReadersAndUpdates.java#L58-L60(10.6.0) の TODO ↩︎ Lucene の開発者向けメーリングリスト dev@lucene.apache.org です。Apache Software Foundation のプロジェクトでは、リリースの投票や設計の議論をこのリストで行います。アーカイブは lists.apache.org で検索できます。確認できた告知は 10.6.0 の ANNOUNCE メール です。 ↩︎
VictoriaLogs 与 Loki、Elasticsearch、OpenSearch、SigNoz、ClickStack 的日志写入性能对比 > This blog post is also available in English: [VictoriaLogs vs Loki, Elasticsearch, OpenSearch, SigNoz and ClickStack on structured log ingestion]( victoriametrics.com) 在 VictoriaMetrics 内部,我们会持续地对自己的产品进行性能测试。这能帮助我们保持竞争力,让每一个版本都变得更好。随着越来越多的团队开始使用 VictoriaLogs ,也有越来越多的人问我们:它适合哪些场景?切换过来能获得什么?因此,我们把基准测试作为一个[公开 GitHub 仓库]( VictoriaMetrics/logs-benchmark)发布出来,任何人都可以运行它来复现我们的结果,我们也会继续以开源的方式维护它。 这不是一篇劝你在迁移到 VictoriaLogs 的广告。相反,如果你在采集结构化日志(无论来自 Kubernetes 还是其他地方),本文应该能帮你选择合适的工具,并了解适合你的权衡取舍。 对不同系统进行比较的基准测试总是充满争议,因为每个系统擅长的场景各不相同。本文只测试一个场景:写入结构化日志,即每一行都带有命名字段的日志,Kubernetes 上的许多服务都是这样输出日志的。每个系统都运行在一台相同规格的机器上。查询性能、集群模式和对象存储会在后续文章中介绍。 仅凭写入速度无法决定该选哪个系统,所以本文也会深入它们的内部实现: - 为什么 Loki 写入速度最快,但一个简单的 Dashboard 查询却要花上几分钟? - 为什么 SigNoz 和 ClickStack 落后于 VictoriaLogs ,尽管三者都以列式方式存储日志? - 为什么 Elasticsearch 和 OpenSearch 需要那么多磁盘空间? 这些问题的答案能帮助你规划硬件和成本,避免在日志量增长时遇到意外。 测试与环境 我们测试了截至 2026 年 10 月 6 个系统的最新版本,配置尽量接近默认值: - VictoriaLogs [v1.53.0]( github.com/VictoriaMetrics/VictoriaLogs/releases/…) - Loki [3.7.8]( github.com/grafana/loki/releases/tag/v3.7.8) - Elasticsearch [9.5.5]( github.com/elastic/elasticsearch/releases/tag/v9.…) - OpenSearch [3.9.0]( github.com/opensearch-project/OpenSearch/releases…) - SigNoz [v0.145.0]( github.com/SigNoz/signoz/releases/tag/v0.145.0) - ClickStack [2.39.1]( github.com/hyperdxio/hyperdx/releases/tag/%40hype…) 我们调整了少量配置,例如 Loki 的写入限制,以便每个系统都能在单节点上达到其设计上限。VictoriaLogs 不需要任何调整,因为它的设计目标就是开箱即用地提供最佳性能([文档]( docs.victoriametrics.com))。[仓库]( github.com/VictoriaMetrics/logs-benchmark/tree/ma…)中说明了每一项改动。 为了让测试保持简单,每个系统都以单节点模式运行在独立的 Kubernetes 节点上( GCP 的 n2d-standard-8 ,8 个 vCPU 、32 GB 内存)。SigNoz 和 ClickStack 将日志存储在 ClickHouse 中,而 ClickHouse 本身无法直接接收日志采集器发送的日志。因此每个技术栈都会在 ClickHouse 所在的节点上运行自己的 Collector ,这些 Collector 基于 OpenTelemetry Collector 构建。  我们同时向每个系统发送相同的日志。写入速率从 10k 行/秒开始,每 10 分钟增加 10k ,最高到 180k 行/秒。一个系统的最大写入速率,是指它在一个完整的 10 分钟阶梯内平均能成功存储的最高速率。如果系统发生崩溃,我们只统计它首次崩溃之前的阶梯,因为崩溃本身就是它的极限。  我们统计的是每个系统实际存储的行数,而不是它接收的行数。例如,SigNoz 和 ClickStack 在 Collector 收到日志后就会立即返回响应,因此 Collector 一旦崩溃,就会丢失已经被接收的日志。Loki 是个例外:它没有低成本的方法统计已存储的行数,但它会在返回响应之前把每一行写入 WAL ( Write-Ahead Log ,预写日志),所以对于 Loki ,我们统计的是接收的行数。 写入速率只是故事的一半。为了展示它与查询速度之间的关系,我们以 35k 行/秒的速率向 VictoriaLogs 、Elasticsearch 和 Loki 写入相同的日志,持续 4 小时,然后在冷缓存状态下对几条常见查询进行计时。我们会在后续文章中深入讨论查询。 每个系统接收的负载 日志可以是任何东西,从纯文本到包含数百个字段的 Wide Events (宽事件)。本测试选取了中间情况:每行包含 30 到 40 个字段的结构化日志,这在 Kubernetes 中很典型。其中约 20 到 30 个字段由应用写入,日志采集器再额外添加约 10 个字段,例如命名空间、节点和标签。 设想一个有 125 个节点的 Kubernetes 集群,每个节点运行 40 个属于不同应用的 Pod 。每个 Pod 的日志构成一个日志流,因此这个集群共有 5,000 个日志流。这是一个中等规模的集群,与我们在 VictoriaLogs 用户中见到的许多集群类似。  我们想看看每个系统基于自身设计能走多远。因此,每个系统都以它偏好的格式接收日志。例如,Loki 通常从 Grafana Alloy 接收 protobuf 格式。我们没有向所有系统发送同一种格式(如 OTLP )。如果那样做,我们测量的就不仅是各系统存储日志的能力,还包括它们转换该格式的效率。 在实际场景中,日志通常以 JSON 形式输出到 stdout 。因此日志采集器需要对其进行转换,而这同样会消耗 CPU 。如果有足够多的读者感兴趣,我们也可以用一种通用格式(如 JSON 或 OTLP )重新运行测试。 测试结果 主写入测试我们运行了 3 次,下面的结果取 3 次运行的中位数。完整的数据表格见[仓库]( github.com/VictoriaMetrics/logs-benchmark/tree/ma…)。 对于 SigNoz 和 ClickStack ,CPU 和内存包含了 Collector 、ClickHouse 及其辅助服务。 本文中提到的少数额外测试(例如查询测试)是仓库之外的一次性运行。 最大写入速率(行/秒)  - Loki 写入最多,达到 139k 行/秒。 - VictoriaLogs 位居第二,为 120k 。 - Elasticsearch 、OpenSearch 和 ClickStack 在 28k 到 48k 之间。 - SigNoz 达到 10k 行/秒,并在 20k 时崩溃。 一行日志约为 930 字节的 JSON ,因此 120k 行/秒约等于 106 MiB/s ,即每天约 9 TiB 的原始日志。 每个数字的背后都伴随着在查询速度、成本或日志类型上的权衡。我们会在[为什么会是这样的结果](#为什么会是这样的结果)中进行解释。 CPU 使用量(核) 各写入速率下的平均 CPU 核数。每条曲线终止于该系统的最大写入速率。  - 在 10k 行/秒(所有系统都能承受的唯一速率)下,VictoriaLogs 和 Loki 使用 0.4 核,ClickStack 0.9 核,Elasticsearch 1.0 核,OpenSearch 1.5 核,SigNoz 4.2 核。 - 差距随速率增长而扩大。在 40k 行/秒时,VictoriaLogs 和 Loki 使用 1.6 核,ClickStack 4.0 核,Elasticsearch 5.2 核。 - 在 120k 行/秒时,Loki 使用 6.1 核,VictoriaLogs 使用 7.2 核。 在本次测试中,CPU 是大多数系统的瓶颈。 内存使用量( GiB ) 各写入速率下的峰值内存,不含操作系统的 Page Cache 。每条曲线终止于该系统能跟上的最后一个速率。  - VictoriaLogs 在所有速率下内存占用都最少,在 40k 行/秒时比 Loki 少 14 倍。 - Elasticsearch 和 OpenSearch 无论速率如何都使用约 17 GiB ,因为它们在启动时会预留固定大小的堆内存。默认情况下,Elasticsearch 会将堆大小设为机器内存的一半([源码]( github.com/elastic/elasticsearch/blob/v9.5.5/dist…))。OpenSearch 不会自动设置堆大小,所以我们给它配置了相同的大小。 - ClickStack 在 10k 行/秒时使用 1.3 GiB ,在 40k 行/秒时使用 2.2 GiB 。 - SigNoz 在 10k 行/秒时使用 3.1 GiB ,这是它在图表中唯一的数据点。 内存决定了机器需要多大、成本多高,以及还剩多少内存可用于缓存和查询。 磁盘空间 下图展示了每个系统存储一百万行日志所需的磁盘空间。作为参考,一百万行日志以原始 JSON 形式约占 887 MiB 。SigNoz 标为 N/A ,因为它的 Collector 反复崩溃,存储的行数太少,且多次运行之间的数据波动太大,无法进行比较。  - VictoriaLogs 所需磁盘空间最少,其次是 ClickStack 和 Loki。 - Elasticsearch 所需空间几乎是 VictoriaLogs 的 5 倍,OpenSearch 则是 10 倍。 日志通常会保留数周,因此磁盘空间增长很快。例如,以 50k 行/秒的速率保存 30 天的日志,VictoriaLogs 约需 7 TiB ,Loki 约需 12 TiB ,Elasticsearch 约需 33 TiB 。 磁盘写入量 下表展示了每个系统每存储一行日志向磁盘写入的字节数。这个数值会大于最终的磁盘占用,因为每个系统都会不时重写数据,以保持数据紧凑并便于搜索。N/A 表示该系统无法跟上对应的速率。 | 系统 | 10k | 20k | 40k | 80k | 120k | 150k | | --- | --- | --- | --- | --- | --- | --- | | Loki (protobuf) | 1,108 | 1,234 | 1,411 | 1,251 | 1,210 | N/A | | VictoriaLogs | 323 | 284 | 226 | 223 | 163 | N/A | | Elasticsearch | 1,393 | 1,815 | 1,917 | N/A | N/A | N/A | | OpenSearch | 2,295 | 3,197 | N/A | N/A | N/A | N/A | | SigNoz | 1,420 | N/A | N/A | N/A | N/A | N/A | | ClickStack | 414 | 461 | 572 | N/A | N/A | N/A | VictoriaLogs 每行写入磁盘的数据量同样最少,而且在更高速率下写得更少,因为一次写入更多行时压缩效果更好。Loki 每行的写入量是它的 3 到 7 倍,具体取决于速率。 这一点很重要,因为每块磁盘每秒能写入的字节数都有上限,而更快的磁盘也更贵。在我们的测试中,使用的是 GCP 上 500 GiB 的 pd-balanced 磁盘,最高写入速度为 280 MiB/s ,所以没有任何系统在耗尽 CPU 之前触及这个上限。尽管如此,VictoriaLogs 能让你使用更便宜的磁盘;在自有硬件上,它对 SSD 的磨损也要慢得多。 为什么会是这样的结果 Loki Loki 的高写入速率让我们感到意外,但它的设计可以解释这一点。Loki 的设计目标是存储大量日志,之后只在较短的时间范围内读取少数几个日志流。因此它只为流标签(这里是节点和应用)建立索引,并原样存储每一行日志。相比之下,VictoriaLogs 会把每一行拆分为字段,并分别存储每个字段,从而让后续的查询更快。在 120k 行/秒时,这会比 Loki 多消耗约 18% 的 CPU 。 向 Loki 发送日志的方式也很重要。Grafana Alloy 和 Vector 默认发送 protobuf ,而 Fluent Bit 、Fluentd 和 Logstash 发送 JSON 。在另一次单独的测试中,使用 JSON 时 Loki 只达到约 99k 行/秒,低于 VictoriaLogs ,因为处理 JSON 比处理 protobuf 消耗的 CPU 多得多。所以如果你使用 Loki ,建议优先选择 Alloy 或 Vector 。 Loki 的设计有 3 个代价。第一个代价是内存。Loki 会把每个流最新的日志行保存在内存中,以便将它们一起压缩。这意味着它的内存会随着流的数量增长。集群越大,流就越多;任何集群在部署期间流也会增多,因为每个新 Pod 都会产生一个新的流。为了观察这会占用多少内存,我们还分别用 10,000 和 15,000 个流运行了测试。  在 15,000 个流时,Loki 在 60k 行/秒时因 OOM 被杀死。而 VictoriaLogs 在流数量增加到 3 倍的情况下,仅多用了 0.2 GiB 内存。更多的内存意味着更大的机器,也意味着可用于缓存查询数据的内存更少。 第二个代价是磁盘。Loki 把日志行当作纯文本进行压缩。它不像 VictoriaLogs 和 ClickHouse 那样利用行内的字段来获得更好的压缩效果,因此它所需的磁盘空间是 VictoriaLogs 的 1.8 倍。 第三个也是最重要的代价是查询时间。Loki 只能通过标签来缩小查询范围。除此之外的任何条件,比如一个单词或一个字段值,都需要对匹配流中的每一行进行解压和扫描。在相同的日志上,前文提到的查询耗时如下。 | 查询问题 | 时间范围 | VictoriaLogs (秒) | Loki (秒) | | --- | --- | --- | --- | | 统计某个应用的错误数 | 1 小时 | 0.06 | 3.5 | | 统计每个应用的错误数 | 1 小时 | 0.59 | 171 | | 包含某个常见词的最新 100 行 | 1 小时 | 2.5 | 32 | | 每个应用的 p99 延迟 | 1 小时 | 1.9 | 302 | | 前 10 个请求路径 | 1 小时 | 8.8 | 被拒绝,超过 500 个 Series | 这些都是排查故障、构建 Dashboard 和告警时的日常问题。除第一条外,Loki 在所有查询上都要花费半分钟甚至更久,或者直接拒绝执行。第一条查询只通过标签读取一个应用的数据,即便如此,它也比 VictoriaLogs 慢了近 60 倍。 Loki 正在增加更快搜索字段的方法,例如 Bloom Filter (布隆过滤器),但这些功能仍处于实验阶段,默认关闭,并且在单节点上不可用,所以我们没有启用它们。 这些缓慢的查询并不意味着 Loki 总体上是一个糟糕的日志方案。它的设计目标是把日志低成本地保存在对象存储中,并在需要执行大型查询时横向扩展到许多查询工作节点上。在我们的单台机器上,统计一小时内每个应用的错误数花了 171 秒,大约使用了 8 核中的一半。粗略估算,若要在约 2 秒内得到结果,同样的扫描大约需要 300 个核。所以,如果你想把大量日志低成本地存储在对象存储中,并且要么只读取其中一小部分,要么愿意为大量 CPU 核付费以实现快速搜索,那么 Loki 适合你。 如果这超出了你愿意承担的成本,而你仍然需要 Dashboard 和搜索在几秒内返回结果,请继续往下读。 Elasticsearch 和 OpenSearch OpenSearch 是 Elasticsearch 的一个 Fork ,所以本节主要介绍 Elasticsearch ,最后再说明 OpenSearch 的不同之处。 Loki 几乎不建索引,并在查询时为此付出代价。Elasticsearch 则恰恰相反。它以 3 种形式存储每一行日志: - 该行的原始 JSON。 - 倒排索引,一个将每个单词和值映射到包含它们的日志行的字典,因此搜索可以直接定位到匹配的行。 - 再以列的形式存储一遍字段( doc values ),用于排序和聚合。  这一切都让写入变得昂贵。在 40k 行/秒时,Elasticsearch 使用了 5.2 核,而 VictoriaLogs 和 Loki 只用了 1.6 核;它在约 48k 行/秒时耗尽了 CPU 。瓶颈在于 CPU ,而不是磁盘。Elasticsearch 会在返回响应前把每个请求写入磁盘,但我们在另一次单独测试中关闭了这一行为,写入速率几乎没有变化。 我们的测试使用了 [LogsDB]( elastic.co),这是 Elasticsearch 针对日志推出的更新、更紧凑的索引模式。但即便如此,Elasticsearch 使用的磁盘空间仍是 Loki 的 2.6 倍,几乎是 VictoriaLogs 的 5 倍。 作为回报,Elasticsearch 查找数据的速度比 Loki 快得多。在 Loki 需要几分钟的查询上,Elasticsearch 只需几秒甚至更短。下面是它与 VictoriaLogs 在几类常见查询上的对比。 | 查询问题 | 时间范围 | VictoriaLogs (秒) | Elasticsearch (秒) | | --- | --- | --- | --- | | 统计包含某个常见词的行数 | 1 小时 | 6.5 | 0.10 | | 统计每个应用的错误数 | 1 小时 | 0.59 | 0.46 | | 每个应用的 p99 延迟 | 1 小时 | 1.9 | 3.9 | | 前 10 个请求路径 | 1 小时 | 8.8 | 14.7 | | 统计包含某个双词短语的行数 | 1 小时 | 4.4 | 175 | Elasticsearch 查找单个词或值几乎是瞬间完成的,在简单计数(例如统计每个应用的错误数)上也更快。而一个短语,比如 "connection timeout",则花了它大约 3 分钟。 为了节省磁盘,它针对日志的默认配置不会存储每个单词在行中的位置,因此它必须重新读取日志行,才能判断两个词是否相邻。VictoriaLogs 在这类查询上更快,在百分位数和 Top 值查询上也更快。 Elasticsearch 之所以流行,是因为它能快速搜索任意字段。人们对它的抱怨通常集中在两点: - 成本。 为每个字段建立索引会消耗大量 CPU 、内存和磁盘。 - 运维。 运维大型集群需要投入大量精力。 Uber 同时遇到了这两个问题。索引成本高昂,而且 Uber 在每个区域运行了 20 多个集群,以确保一个集群故障不会影响全局([Uber]( uber.com))。Cloudflare 则不得不对部分日志进行采样,因为全部存储需要数百 TB 的空间([Cloudflare]( blog.cloudflare.com))。两家公司都把部分最大规模的日志负载迁移到了 ClickHouse 。 OpenSearch 的写入量略高于 Elasticsearch 的一半( 28k 对 48k 行/秒),磁盘占用约为后者的两倍。它与 Elasticsearch 在两项默认设置上有所不同: - 它没有类似 LogsDB 的模式,因此对每行原始 JSON 的压缩没那么紧凑([OpenSearch 文档]( docs.opensearch.org)),也不会预先排序。 - 它没有内置的日志模板,因此大多数字符串字段会被索引两次:一次按分词,一次按精确值。 OpenSearch 与 Elasticsearch 共享相同的内核,因此通过更强的压缩、排序以及完整的日志模板,你很可能可以弥补大部分差距。我们刻意让它保持接近默认配置,以展示开箱即用时的表现。 Loki 以牺牲查询速度为代价换取快速写入。Elasticsearch 以牺牲写入速度和存储为代价换取快速搜索。那么 VictoriaLogs 又提供了什么呢? VictoriaLogs 如今,许多公司都在输出结构化日志,每一行都带有命名字段,例如日志级别、HTTP 状态码或请求耗时。VictoriaLogs 利用了这类日志的两个特性,使写入和查询都能保持高速。 1. 同一个流中的日志看起来很相似。它们的字段几乎相同,而且许多字段几乎不变。每一行的命名空间都相同,日志级别也只有少数几个取值。VictoriaLogs 会把每个流的日志存放在一起,并把每个字段存储在独立的列中,这比存储整行日志的压缩效果好得多。 2. 许多查询只需要少数几个字段。例如,统计每个应用的错误数只需要应用和级别两个字段。VictoriaLogs 只读取查询所需的列。  这种存储方式本身就让 VictoriaLogs 在磁盘上非常紧凑,聚合也很快,所以它在写入时只需构建轻量的索引和少量元数据。这就是为什么它能以 120k 行/秒的速率写入,是 Elasticsearch 的 2.5 倍,同时磁盘占用少 4.7 倍。正如前文所述,只有 Loki 的写入速度更快。更多细节请参阅 [VictoriaLogs 如何存储你的日志]( victoriametrics.com)。 在查询方面,VictoriaLogs 在几秒内响应了所有查询,并且在每一条查询上都比 Loki 快。与 Elasticsearch 相比则互有胜负,我们会在关于查询的文章中详细分析。为了保持低成本的写入和存储,VictoriaLogs 不会构建完整的倒排索引。其代价是,搜索一个常见词(出现在大量行中的词)会比较慢。而查找一个罕见词(例如用户 ID )依然很快,因为 Bloom Filter 可以让它跳过不可能包含该词的数据。 VictoriaLogs 使用的内存也少得多。在 40k 行/秒时,Loki 使用的内存约为它的 14 倍( 11.4 GiB 对 0.8 GiB );在 120k 行/秒时约为 5 倍( 12.8 GiB 对 2.5 GiB )。这些空闲内存并不会被浪费。操作系统会用它来缓存最近写入或读取的数据,因此许多查询(尤其是针对近期日志的查询)可以直接从内存而不是磁盘读取数据。它也为大量并发查询留出了空间,我们计划在后续关于查询的文章中对此进行测试。 公平地说,本次基准测试使用的是结构化日志,这正是 VictoriaLogs 的优势所在。而针对这类日志的日常工作,很大一部分也是用于 Dashboard 、告警和分析的聚合,这正是列式存储最能发挥作用的地方。如果你的日志是非结构化的,例如老旧应用和网络设备输出的纯文本日志,VictoriaLogs 依然能很好地写入和搜索它们,但收益会比结构化日志小一些。 SigNoz 和 ClickStack SigNoz 和 ClickStack 比 Loki 更让我们意外。两者都把日志存储在 ClickHouse 中,而 ClickHouse 和 VictoriaLogs 一样是列式数据库,所以我们原本预期它们的表现会接近 VictoriaLogs 。但事实并非如此。ClickStack 达到 46k 行/秒,SigNoz 只有 10k ,而 VictoriaLogs 为 120k 。在磁盘空间方面,ClickStack 排名第二,但每百万行仍需要 82 MiB ,是 VictoriaLogs 的 1.5 倍。 较低的写入速率来自两个搭建在 ClickHouse 之上的方案,来自于它们的 Collector 。ClickHouse 是一个通用数据库,无法直接接收常见格式的日志,因此需要由一个 OpenTelemetry Collector 先把日志转换成行数据。这个 Collector 消耗的 CPU 比 ClickHouse 本身还多。在 SigNoz 中,10k 行/秒时 Collector 使用了 2.7 核,而 ClickHouse 为 1.5 核;在 ClickStack 中,达到最大速率时两者分别为 3.3 核和 2.2 核。  更大的磁盘占用可能源于两个技术栈存储字段的方式。它们把日志存储在一张固定列的 ClickHouse 表中,但 [OpenTelemetry 日志格式]( opentelemetry.io) 只有少数几个固定字段,外加可以容纳任意自定义数据的 Attributes 。固定字段(如 Timestamp 、SeverityText 和 TraceId )拥有独立的列,压缩效果很好。而 Attributes 则被放入少数几个 Map 列中,许多字段的值混杂在一起,因此压缩效果较差,查询也更慢。 VictoriaLogs 受到了 ClickHouse 的启发,但它是专门为日志而构建的([FAQ]( docs.victoriametrics.com))。它与 ClickHouse 有两点不同,这很可能解释了写入速率和磁盘空间上的大部分差距: - 无需 Collector 。 VictoriaLogs 可以直接接收日志采集器已经在发送的各种格式,例如 OpenTelemetry 、Loki 、Elasticsearch 、Datadog 和 Splunk 。 - 无需 Schema 。 日志行中的每个字段都有自己独立的列,而不仅仅是少数几个固定字段。 在本次测试中,基于相同设计的 SigNoz 使用的 CPU 几乎是 ClickStack 的 5 倍,主要消耗在其 Collector 上。Collector 把这些 CPU 花在了额外的元数据上,这些元数据可以加速查询并为其 UI 提供支持,我们会在关于查询的文章中探讨这一权衡。此外,它默认不设内存上限,而我们保留了这一默认配置。因此当它处理不过来时,会把日志行一直堆积在内存中,直到被 Kubernetes OOM Kill 。其他用户也报告过类似的 Collector 崩溃问题([GitHub issue]( signoz#6128))。 公平地说,SigNoz 和 ClickStack 是完整的可观测性技术栈,它们把日志、指标和链路追踪汇集在一处,并把每条链路关联到对应的日志。本次测试只衡量了其中的一部分:日志写入。两个技术栈也都建议随着数据量增长增加更多的 Collector 。ClickHouse 的文档按约 3 核处理 60k 事件/秒来规划单个 Collector ([ClickHouse 文档]( clickhouse.com)),而 SigNoz 自己的基准测试在一台 16 核机器上,于 ClickHouse 旁运行了 3 个 Collector ,达到约 55k 行/秒([SigNoz]( signoz.io))。在我们的测试中,为了对所有系统公平,每个系统都只分配了一台 8 核机器,所以 Collector 不得不与 ClickHouse 共享资源。集群测试中各个组件可以独立扩展,能够展示它们从更大规模的部署中能获得多少收益,这也在我们的计划之中。 关键结论 每个系统都根据自身的优先级和架构做出了不同的权衡。 - Loki 写入最多,因为它建立的索引最少。代价是查询缓慢,除非你为更多 CPU 核付费来加速查询。 - Elasticsearch 和 OpenSearch 为每一行的每一个词建立索引。这让它们能够非常快速地查找单词和精确值。但代价是写入时 CPU 消耗高,并且需要大量存储空间。 - SigNoz 和 ClickStack 把日志存储在 ClickHouse 中。ClickHouse 并非专为日志而构建,因此两个技术栈都需要额外的组件,这会拖慢写入速度并增加成本。如果你希望把 OTLP 日志、指标和链路追踪集中在一处,它们会比较适合你。 - VictoriaLogs 受到 ClickHouse 的启发,但它是直接为日志而构建的。它充分利用现代结构化日志的特点,实现快速写入、秒级查询响应,并且在内存和磁盘上的成本最低。如果你的日志是纯文本,收益会小一些。 本文在单台机器、普通云盘上测试了结构化日志。在后续文章中,我们计划深入介绍查询、集群模式,以及在 VictoriaLogs [支持对象存储]( VictoriaLogs#1155)后的对象存储测试。 你可以使用我们的[基准测试仓库]( VictoriaMetrics/logs-benchmark)复现主要的写入测试结果,也可以通过[快速入门指南]( docs.victoriametrics.com)试用 VictoriaLogs 。如果你发现我们的测试环境有错误,或者知道如何更好地调优某个系统,欢迎在[仓库]( github.com/VictoriaMetrics/logs-benchmark/issues)中提交 issue ,或在 [VictoriaMetrics 的 Slack]( slack.victoriametrics.com) 上联系我们。
OpenSearchのLLM-as-a-Judgeを日本語の商品検索で動かして、人手ラベルと比べてみた はじめに Search Relevance Workbench(SRW)は、OpenSearch 3.1で導入された検索品質を評価するための機能です。クエリと検索設定、正解データを組み合わせてNDCGなどの指標を計算できます。正解データは自分で用意するのが一般的ですが、SRWにはLLMに検索結果の関連度を採点させて正解データを作る、LLM-as-a-Judgeという機能も用意されています。 英語の紹介記事はありましたが、日本語のデータで試した例は見当たらなかったので、今回はAmazonの商品検索データセット(ESCI)の日本語データを使って実際に動かしてみました。ESCIには人が付けた関連度ラベルがあるので、いくつかの例でLLMの採点と見比べてみます。 使ったものは以下のとおりです。 OpenSearch / OpenSearch Dashboards 3.9.0(Docker) analysis-kuromoji 3.9.0 Gemini 3.5 Flash-Lite(Gemini API) Shopping Queries Dataset(ESCI)の日本語データ Python 3.12(uv) SRWとESCIについて SRWは、以下の4つの部品を組み合わせて検索品質を評価します。 Query set:評価に使うクエリ Search configuration:検索の設定(どのインデックスを、どんなクエリで検索するか) Judgment list:正解データ(クエリ・文書・評価値の組) Experiment:検索設定の結果を正解データで採点して、NDCGなどを計算する 正解データは、外部で作った評価値を取り込む(IMPORT_JUDGMENT)こともできますが、今回はLLMに検索結果を採点させるLLM_JUDGMENTを試します。採点は0〜1の段階評価(SCORE0_1)か、関連あり/なしの二択(RELEVANT_IRRELEVANT)を選べます。 ESCIはAmazonが公開している商品検索のデータセットで、クエリと候補商品の組に、人が以下の4段階のラベルを付けています。 E(Exact):条件をすべて満たす S(Substitute):一部違うが代わりに使える C(Complement):一緒に使うもの I(Irrelevant):無関係 環境構築 Dockerを使ってOpenSearchとDashboardsを起動します。Dockerfileでは、OpenSearch公式のDockerイメージをベースに、日本語の形態素解析を行うkuromojiプラグインをインストールします。 docker/opensearch/Dockerfile ARG OPENSEARCH_VERSION=3.9.0 FROM opensearchproject/opensearch:${OPENSEARCH_VERSION} RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-kuromoji 3.9.0のSRWプラグインにはslf4j-apiが入っておらず、LLM_JUDGMENTでLLMの応答を受け取ると NoClassDefFoundError: org/slf4j/LoggerFactoryでOpenSearchが落ちる。ML Commonsのものをコピーして補う RUN cp /usr/share/opensearch/plugins/opensearch-ml/slf4j-api-1.7.36.jar \ /usr/share/opensearch/plugins/opensearch-search-relevance/ 1ノード構成でコンテナを定義します。今回は動作確認が目的なので、セキュリティ設定を無効化しています。 docker/docker-compose.yml name: srw-esci-ja services: opensearch: build: context: ./opensearch args: OPENSEARCH_VERSION: 3.9.0 image: srw-esci-ja/opensearch-kuromoji:3.9.0 container_name: srw-opensearch cpus: 4 # LLM_JUDGMENTが同時にLLMを呼ぶ数はCPUの数で決まるので、APIの回数制限に当たらないよう絞る environment: - discovery.type=single-node - bootstrap.memory_lock=true - OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g - DISABLE_INSTALL_DEMO_CONFIG=true # 動作確認用なのでdisableにする - DISABLE_SECURITY_PLUGIN=true # 動作確認用なのでdisableにする ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 volumes: - opensearch-data:/usr/share/opensearch/data ports: - 9200:9200 dashboards: image: opensearchproject/opensearch-dashboards:3.9.0 container_name: srw-dashboards environment: - OPENSEARCH_HOSTS=["opensearch"] - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true # 動作確認用なのでdisableにする ports: - 5601:5601 depends_on: - opensearch volumes: opensearch-data: コンテナを起動します。 $ docker compose -f docker/docker-compose.yml up -d --build 商品データを入れる ESCIのデータは、amazon-science/esci-dataのリポジトリからダウンロードします。parquetファイルはGit LFSで管理されているので、github.com/.../raw/...のURLを指定します。 $ curl -L -O github.com/amazon-science/esci-data/raw/refs/head… $ curl -L -O github.com/amazon-science/esci-data/raw/refs/head… ESCIのラベルは、クエリごとの候補商品にしか付いていません。今回は日本語データから一部のクエリを選び、その候補商品だけをインデックスに入れます。 ここからのOpenSearchへのリクエストは、DashboardsのDev Tools(localhost)から実行します。 まず、以下のマッピングでproducts_kuromojiインデックスを作成します。テキストのフィールドにはkuromojiを使い、どのクエリの候補かを表すcandidate_queriesは、完全一致で検索するためにkeyword型にしています。 PUT products_kuromoji { "mappings": { "properties": { "product_id": { "type": "keyword" }, "product_title": { "type": "text", "analyzer": "kuromoji" }, "product_brand": { "type": "text", "analyzer": "kuromoji" }, "product_bullet_point": { "type": "text", "analyzer": "kuromoji" }, "product_description": { "type": "text", "analyzer": "kuromoji" }, "candidate_queries": { "type": "keyword" } // どのクエリの候補か } } } 作成したインデックスに、候補商品をPythonで取り出して投入しました。 LLMをつなぐ SRWは、ML Commonsのコネクタを経由して外部のLLMを呼び出します。ml-commonsのリポジトリにSRW用のGeminiコネクタの例があるので、それを参考にしました。今回はLLMにGemini 3.5 Flash-Liteを使います。APIキーはGoogle AI Studioで発行したものを使います。 まず、コネクタの接続先を許可します。 PUT _cluster/settings { "persistent": { "plugins.ml_commons.trusted_connector_endpoints_regex": [ "^generativelanguage\.googleapis\.com/.*$" ], "plugins.ml_commons.only_run_on_ml_node": false } } 次に、コネクタを登録します。例から変えたのは、タイムアウトと再試行の設定(client_config)と、LLMの応答を読み取る処理(post_process_function)の2か所です。デフォルトのタイムアウト(30秒)と再試行(0回)では足りなかったので延ばしています。また、Gemini 3.5 Flash-Liteでは、採点結果のJSONがエスケープされた形({\"ratings\": ...)で返ってくることがあり、そのままだとSRWが読めずに未採点になってしまうので、replaceで戻してから渡しています。 POST _plugins/_ml/connectors/_create { "name": "Gemini gemini-3.5-flash-lite", "version": "1", "protocol": "http", "parameters": { "model": "gemini-3.5-flash-lite" }, "credential": { "gemini_key": "<API キー>" }, "client_config": { "read_timeout": 180, "max_retry_times": 5, "retry_backoff_policy": "exponential_full_jitter", "retry_backoff_millis": 5000, "retry_timeout_seconds": 300, "connection_timeout": 30 }, "actions": [ { "action_type": "predict", "method": "POST", "url": "generativelanguage.googleapis.com", "headers": { "Content-Type": "application/json", "x-goog-api-key": "${credential.gemini_key}" }, "request_body": "{\"systemInstruction\":{\"parts\":[{\"text\":\"${parameters.system_prompt}\"}]},\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"${parameters.user_prompt}\"}]}]}", "post_process_function": "def text = params.candidates[0].content.parts[0].text.replace('\\\"', '\"'); return '{\"name\":\"response\",\"dataAsMap\":{\"response\":\"' + escape(text) + '\"}}'" } ] } 最後に、モデルとして登録してデプロイします。 POST _plugins/_ml/model_groups/_register { "name": "srw_llm_judgment" } POST _plugins/_ml/models/_register?deploy=true { "name": "Gemini gemini-3.5-flash-lite", "function_name": "remote", "model_group_id": "<model_group_id>", "connector_id": "<connector_id>" } LLMに採点させてみる 商品データを入れたproducts_kuromojiインデックスで、「電源なしスピーカー」の検索結果の上位5件をLLMに採点させてみます。 まず、クエリセットと検索設定を登録します。検索設定の%SearchText%には、クエリセットのクエリが入ります。 PUT _plugins/_search_relevance/query_sets { "name": "handson", "sampling": "manual", "querySetQueries": [{ "queryText": "電源なしスピーカー" }] } PUT _plugins/_search_relevance/search_configurations { "name": "bm25-kuromoji", "index": "products_kuromoji", "query": "{\"query\":{\"multi_match\":{\"query\":\"%SearchText%\",\"fields\":[\"product_title^2\",\"product_brand\",\"product_bullet_point\",\"product_description\"]}}}" } LLM_JUDGMENTで採点させます。 PUT _plugins/_search_relevance/judgments { "name": "handson-score", "type": "LLM_JUDGMENT", "modelId": "<model_id>", "querySetId": "<query_set_id>", "searchConfigurationList": ["<search_configuration_id>"], "size": 5, // 検索結果の上位何件を採点するか "contextFields": ["product_title", "product_brand", "product_bullet_point"], // LLMに渡すフィールド "llmJudgmentRatingType": "SCORE0_1" // 0〜1の段階評価 } 採点は非同期で進むので、少し待ってから結果を取得します。 GET _plugins/_search_relevance/judgments/<judgment_id> statusがCOMPLETEDになっていて、商品ごとに点数が付いていれば成功です。一部の商品が採点できなかった場合もCOMPLETEDになるので、failuresに商品が入っていないかも確認します。入っていた場合は、POST _plugins/_search_relevance/judgments/<judgment_id>/_retryで再実行できます。 結果を商品名と並べると、以下のようになりました。 順位 商品(タイトルの要約) LLM 1 木製無電源スピーカー 1.0 2 PC用スピーカーUSB電源 0.2 3 ポータブルスピーカー(電源不要) 0.8 4 ミニスピーカーUSB電源 0.3 5 木製スピーカー(電源不要) 1.0 電源を使わないスピーカーは0.8〜1.0と高く、USB給電のスピーカーは0.2〜0.3と低くなっていて、「電源なし」を文字どおりに解釈して採点しているようです。 人手ラベルと比べてみる ESCIには人手で付けた関連度ラベルがあるので、LLMの採点と比べてみます。いくつか試してみた中で、うまくいった例と、人手ラベルとずれた例を見てみます。 LLM_JUDGMENTは、検索結果の上位の商品だけを採点します。今回は人手ラベルと同じ商品で比べたいので、検索の代わりに、そのクエリの候補商品(人手ラベルが付いている商品)をすべて返す検索設定を、先ほどのマッピングで用意したcandidate_queriesを使って作りました。 PUT _plugins/_search_relevance/search_configurations { "name": "esci-candidates", "index": "products_kuromoji", "query": "{\"query\":{\"term\":{\"candidate_queries\":\"%SearchText%\"}}}" } この検索設定を使って、LLM_JUDGMENTを実行します。 PUT _plugins/_search_relevance/judgments { "name": "full-score01", "type": "LLM_JUDGMENT", "modelId": "<model_id>", "querySetId": "<query_set_id>", "searchConfigurationList": ["<esci-candidates の ID>"], "size": 40, // 候補の最大件数 "tokenLimit": 50000, // 1リクエストのトークン上限で、分割を減らすため大きめに設定 "contextFields": ["product_title", "product_brand", "product_bullet_point"], "llmJudgmentRatingType": "SCORE0_1" } 取得した点数を、ESCIの人手ラベルと並べてみます。 人手ラベルと傾向が近かった例:液体ミルク 人手ラベルがEの液体ミルクは、LLMもすべて1.0にしていました。Sの粉ミルクやキューブタイプのミルクは0.3〜0.4で、人手ラベルと整合的な傾向が見られました。 商品(タイトルの要約) LLM 人手ラベル 明治 ほほえみ らくらくミルク240ml(液体ミルク) 1.0 E アイクレオ 赤ちゃんミルク125ml(液体ミルク) 1.0 E 明治 ほほえみ らくらくキューブ 0.4 S 和光堂 レーベンスミルク はいはい(粉ミルク) 0.3 S 人手ラベルとずれた例:電源なしスピーカー 先ほど試したクエリです。人手ラベルは多くの商品がEですが、LLMは電源をまったく使わないスピーカーだけを高く採点していました。 商品(タイトルの要約) LLM 人手ラベル 木製無電源スピーカー 1.0 E ミニスピーカーUSB電源 0.3 E Bluetoothスピーカー(充電式) 0.2 E スピーカーケーブル 0.1 E 人手ラベルでは、USB給電や充電式のスピーカーもEになっています。たとえば「ミニスピーカーUSB電源」は人手ラベルがEですが、LLMは0.3でした。USBから給電する点を、LLMは「電源あり」と判断したと考えられます。同じように「Bluetoothスピーカー(充電式)」もEですが、LLMは0.2で、充電が必要なことから低く採点したようです。「電源なし=コンセントが不要」と読めば、これらはEとも言えるので、ここはLLMと人手の解釈の違いのようです。一方で、「スピーカーケーブル」は人手ラベルがE、LLMは0.1でした。スピーカー本体ではないため、LLMの採点のほうが自然に見えます。ケーブルがEになっている点は解釈の違いでは説明しにくく、人手ラベル自体にも揺れがあるようです。 終わりに 今回は、OpenSearchのSRWのLLM-as-a-Judgeを日本語の商品データで試してみました。Gemini 3.5 Flash-Liteでの採点は、人手ラベルと傾向が近いクエリもあれば、大きくずれるクエリもあり、結果はモデルによっても変わりそうです。今後としては、上位のモデルやプロンプトを変えて、より多くのクエリで比較してみたいです。 参考 amazon-science/esci-data(ライセンス:Apache-2.0) Reddy et al., Shopping Queries Dataset: A Large-Scale ESCI Benchmark for Improving Product Search (2022) OpenSearch Docs: Search Relevance Workbench OpenSearch Docs: Judgments ml-commons: Google Gemini connector blueprint for LLM judgment
opensearchコンテナから、fluentdの情報+sysstatの情報を収集する <!-- Qiita投稿用メモ(本文には含めない) タイトル案: OpenSearch 3.x + Fluentd で journald のログを集める自前ログ基盤を一から作る(Docker / TLS / 送信元IP制限) タグ案: OpenSearch, Fluentd, Docker, Ubuntu, journald --> この記事について 本記事はAIで情報をまとめている。 複数台のUbuntuサーバーが出力するログを、1台のOpenSearchに集約するログ基盤の構築手順をまとめる。 ログの送信にはFluentdを使い、journaldに流れているアプリケーションログとシステムログ、さらにsysstatのリソース使用状況まで取り込む。 実際に構築した際に何度もつまずいた箇所があったため、手順の中にその理由と確認コマンドを埋め込んである。 上から順に実行すれば、初めての人でも同じ構成を再現できることを目標にしている。 なお、OpenSearch Dashboards上でグラフやダッシュボードを作る手順は扱わない。 本記事のゴールは、ログがOpenSearchに届き、DashboardsのDiscover画面で検索できる状態までである。 :::note info 記事中のIPアドレス、ホスト名、ユーザー名、パスワードはすべてダミーまたはプレースホルダーに置き換えている。 <LOG_SERVER_IP> のように < > で囲まれた部分は、自分の環境の値に読み替えること。 ::: 構成と前提 全体構成 [app01] Fluentd ─┐ [app02] Fluentd ─┼── HTTPS(9200) + Basic認証 ──> [log-server] OpenSearch [app03] Fluentd ─┘ 送信元IPで制限 │ │ コンテナ間通信 v [手元PC] ── SSHトンネル(5601) ─────────────────> [log-server] OpenSearch Dashboards | 役割 | ホスト名(本記事での呼称) | 備考 | | --- | --- | --- | | ログ集約サーバー | log-server | OpenSearchとDashboardsをDockerで動かす | | ログ送信元サーバー | app01 app02 app03 | Fluentdを導入する | | 閲覧者の手元PC | - | SSHトンネル経由でDashboardsを見る | 使用するソフトウェアとバージョン | ソフトウェア | バージョン | | --- | --- | | OS | Ubuntu 22.04 LTS(jammy)。送信元の一部が20.04(focal)でも可 | | OpenSearch / OpenSearch Dashboards | 3.8.0(公式Dockerイメージ) | | Fluentd | fluent-package 6 LTS(focalでは5 LTS) | | Fluentdプラグイン | fluent-plugin-systemd、fluent-plugin-opensearch | 設計上の判断 OpenSearchの9200番ポートは、ログ集約サーバーのグローバルIP上で待ち受ける。 そのうえで、送信元サーバーのIPアドレス以外からの接続をファイアウォールで遮断し、通信自体はTLSとBasic認証で保護する。 ドメインを割り当てていないため、証明書は自己署名で作成する。 ドメインを取得できる環境であれば、Let's Encrypt等の正式な証明書に切り替えることを推奨する。 Dashboardsはループバックアドレスでのみ待ち受け、外部には公開しない。 閲覧する際はSSHのポートフォワードを使う。 ログは送信元サーバーごとにインデックスを分けず、共有のインデックスに格納する。 どのサーバーのログかは、Fluentdが付与する hostname フィールドで区別する。 この方式にすると、Fluentdの設定ファイルを全サーバーで同一の内容にできる。 作成するインデックス | インデックス名 | 内容 | 保持期間(初期値) | | --- | --- | --- | | logs-app-YYYY.MM.DD | アプリケーション(myapp@*.service)のログ | 31日 | | logs-system-YYYY.MM.DD | journald全体のシステムログ(任意) | 7日 | | logs-sysstat-YYYY.MM.DD | CPU、メモリ、ディスクI/Oの使用状況(任意) | 14日 | 前提: アプリケーションのログがjournaldに流れていること 本記事では、送信元サーバーで docker compose up の標準出力がjournaldに流れている構成を前提とする。 すでに同様の仕組みがある場合は、この節は読み飛ばしてよい。 まだ仕組みがない場合は、以下のようなsystemdのテンプレートユニットを用意すると、/opt/apps/<アプリ名>/ に置いた docker-compose.yml を myapp@<アプリ名>.service として起動でき、その出力がjournaldに記録される。 【app01 / app02 / app03】 sudo tee /etc/systemd/system/myapp@.service > /dev/null <<'EOF' [Unit] Description=myapp %i (docker compose) After=docker.service Requires=docker.service [Service] WorkingDirectory=/opt/apps/%i ExecStart=/usr/bin/docker compose up --no-color ExecStop=/usr/bin/docker compose down Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now myapp@testapp.service journalctl -u myapp@testapp.service -n 5 --no-pager docker compose up の出力は testapp-web-1 | GET / 200 のように、コンテナ名と本文が | で区切られた形式になる。 後の手順で、Fluentdはこの | を境に container_name と log_message の2つのフィールドに分割する。 --- 1. 送信元サーバーのグローバルIPを確認する ログ集約サーバーのファイアウォールに登録するため、各送信元サーバーのグローバルIPを確認しておく。 【app01 / app02 / app03(それぞれで実行)】 curl -s https://ifconfig.me; echo 以降、それぞれ <APP01_IP> <APP02_IP> <APP03_IP> と表記する。 :::note warn 送信元のグローバルIPが変動する契約の場合、IPアドレスで制限するこの方式は成り立たない。 固定IPであることを事前に確認しておくこと。 ::: --- 2. ログ集約サーバーにDockerを導入する Docker公式の「Install using the apt repository」の手順に従う。 すでにDockerとDocker Compose v2が導入済みであれば、この章は不要である。 参考: Install Docker Engine on Ubuntu | Docker Docs 【log-server】 # 競合する旧パッケージの削除(入っていなければエラーは無視してよい) for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt remove -y $pkg 2>/dev/null || true done sudo apt update sudo apt install -y ca-certificates curl sudo install -m 0755 -d /etc/apt/keyrings sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc sudo chmod a+r /etc/apt/keyrings/docker.asc echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin sudo usermod -aG docker $USER 一度ログアウトして再ログインし、バージョンが表示されることを確認する。 【log-server】 docker --version docker compose version --- 3. ログ集約サーバーのカーネルパラメータを設定する OpenSearchは vm.max_map_count が262144以上であることを必須要件としている。 これを満たさないと、コンテナが起動直後に停止する。 参考: Important system settings - OpenSearch Documentation 【log-server】 sudo sysctl -w vm.max_map_count=262144 echo "vm.max_map_count=262144" | sudo tee -a /etc/sysctl.conf --- 4. ログ集約サーバーのファイアウォールを設定する Dockerの公開ポートがufwを経由しない問題 この章は構築全体の中で最も見落としやすい。 Dockerで ports: "9200:9200" のようにポートを公開すると、DockerはiptablesのNATテーブルとFORWARDチェーンに直接ルールを追加する。 一方、ufwのルールはINPUTチェーンに書き込まれる。 そのため、公開ポート宛てのパケットはufwのルールを通らずにコンテナへ届いてしまう。 つまり ufw allow from <IP> to any port 9200 と設定しても、実際には全世界から9200番に接続できる状態のままになる。 これはDocker社自身も認識している既知の挙動である。 対策として、Dockerが自身のルールより先に評価する DOCKER-USER チェーンにルールを入れる。 このチェーンはDockerの起動時に上書きされないため、ufwの起動時に読み込まれる /etc/ufw/after.rules に書いて永続化する。 参考: - Best Practices for Docker and UFW · Issue #777 · docker/for-linux - chaifeng/ufw-docker - Docker Container Published Port Ignoring UFW Rules | Baeldung on Linux SSHなど通常のポートはufwで制御する Dockerで公開していないポートは、通常通りufwで制御できる。 SSHの接続元を限定できる場合は、ufw allow OpenSSH で全開放するより、送信元IPを絞るほうが安全である。 【log-server】 sudo ufw status verbose sudo ufw allow from <YOUR_ADMIN_IP> to any port <SSH_PORT> proto tcp sudo ufw enable :::note warn ufw enable の前に、今使っているSSH接続が許可ルールに含まれていることを必ず確認すること。 誤ると自分自身を締め出してしまう。 ::: 9200番はDOCKER-USERチェーンで制御する 【log-server】 sudo vi /etc/ufw/after.rules ファイルの末尾に以下を追記する。 送信元サーバーの台数に合わせて ACCEPT の行を増減させる。 *filter :DOCKER-USER - [0:0] -A DOCKER-USER -s <APP01_IP>/32 -p tcp --dport 9200 -j ACCEPT -A DOCKER-USER -s <APP02_IP>/32 -p tcp --dport 9200 -j ACCEPT -A DOCKER-USER -s <APP03_IP>/32 -p tcp --dport 9200 -j ACCEPT -A DOCKER-USER -p tcp --dport 9200 -j DROP -A DOCKER-USER -j RETURN COMMIT 反映し、DOCKER-USERチェーンに意図したルールが入っているか確認する。 【log-server】 sudo ufw reload sudo iptables -L DOCKER-USER -n -v 期待される結果: 送信元IPごとの ACCEPT の行と、それ以外を落とす DROP の行が表示されること。 :::note info Dockerのバージョンアップやサーバーの再起動のあとは、sudo iptables -L DOCKER-USER -n -v でルールが残っているか再確認する習慣をつけておくとよい。 ufw status の表示だけでは、このチェーンの状態は分からない。 ::: --- 5. TLS証明書を作成する(自己署名) 参考: Generating self-signed certificates - OpenSearch Documentation 作業用のディレクトリを作成する。 以降、OpenSearch関連のファイルはすべて /opt/log-stack に置く。 【log-server】 sudo mkdir -p /opt/log-stack/certs sudo chown -R $USER:$USER /opt/log-stack cd /opt/log-stack/certs ルートCA 【log-server】 openssl genrsa -out root-ca-key.pem 2048 openssl req -new -x509 -sha256 -key root-ca-key.pem -out root-ca.pem -days 3650 \ -subj "/C=JP/ST=Tokyo/O=Example/CN=example-log-root-ca" ノード証明書 FluentdはTLS接続時に、接続先のアドレスと証明書の内容が一致するかを検証する。 本記事ではIPアドレスで接続するため、CNとSAN(subjectAltName)の両方にログ集約サーバーのグローバルIPを入れる。 【log-server】 openssl genrsa -out node-key-temp.pem 2048 openssl pkcs8 -inform PEM -outform PEM -in node-key-temp.pem -topk8 -nocrypt -v1 PBE-SHA1-3DES -out node-key.pem openssl req -new -key node-key.pem -out node.csr \ -subj "/C=JP/ST=Tokyo/O=Example/CN=<LOG_SERVER_IP>" echo "subjectAltName=IP:<LOG_SERVER_IP>" > node-san.ext openssl x509 -req -in node.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \ -sha256 -out node.pem -days 3650 -extfile node-san.ext 管理者証明書 管理者証明書は、パスワードを使わずにOpenSearchのセキュリティ設定を確認、変更するために使う。 パスワード認証でつまずいた際の切り分けにも役立つ。 【log-server】 openssl genrsa -out admin-key-temp.pem 2048 openssl pkcs8 -inform PEM -outform PEM -in admin-key-temp.pem -topk8 -nocrypt -v1 PBE-SHA1-3DES -out admin-key.pem openssl req -new -key admin-key.pem -out admin.csr \ -subj "/C=JP/ST=Tokyo/O=Example/CN=admin" openssl x509 -req -in admin.csr -CA root-ca.pem -CAkey root-ca-key.pem -CAcreateserial \ -sha256 -out admin.pem -days 3650 証明書のDN文字列を取得する -subj で指定した文字列と、証明書に実際に埋め込まれるDN(識別名)は、順序も区切り文字も一致しない。 後で opensearch.yml に書く admin_dn と nodes_dn には、以下のコマンドで取得した文字列を使う必要がある。 【log-server】 openssl x509 -subject -nameopt RFC2253 -noout -in node.pem openssl x509 -subject -nameopt RFC2253 -noout -in admin.pem 出力は以下のように subject= という接頭辞付きで表示される。 subject=CN=<LOG_SERVER_IP>,O=Example,ST=Tokyo,C=JP subject=CN=admin,O=Example,ST=Tokyo,C=JP :::note warn opensearch.yml に書くのは subject= を取り除いた CN=...,O=...,ST=...,C=JP の部分だけである。 subject= を含めたまま書くと、証明書による権限チェックに失敗する。 ::: 不要ファイルの削除とパーミッション設定 【log-server】 rm -f node-key-temp.pem admin-key-temp.pem *.csr node-san.ext chmod 644 *.pem cd /opt/log-stack パーミッションを 600 ではなく 644 にしているのには理由がある。 OpenSearchの公式Dockerイメージはコンテナ内部で非rootユーザーとして動作するため、600 のままだと Unable to read the file root-ca.pem のようなエラーで起動に失敗する。 --- 6. 管理者パスワードのハッシュ値を生成する なぜ自分でハッシュを作るのか OpenSearchの公式イメージには、OPENSEARCH_INITIAL_ADMIN_PASSWORD という環境変数で管理者パスワードを設定する仕組みがある。 しかしこれを担っているのは、イメージ内蔵の「デモ設定インストーラー」である。 本記事のように独自の opensearch.yml を用意すると、このインストーラーは opensearch.yml seems to be already configured for Security. Quit. と出力して処理をスキップする。 その結果、環境変数にパスワードを書いても一切反映されない。 そのため、パスワードのハッシュ値は自分で生成し、internal_users.yml に直接書き込む。 参考: - Seems to be already configured for Security. Quit - OpenSearch Forum - Modifying the YAML files | OpenSearch Documentation ハッシュの生成 イメージに同梱されている hash.sh を、使い捨てコンテナで実行する。 admin 用と、Dashboardsが内部通信に使う kibanaserver 用の2つを生成する。 【log-server】 # admin用(大文字、小文字、数字、記号を含む十分に長いパスワードにする) docker run --rm opensearchproject/opensearch:3.8.0 \ /usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p '<ADMIN_PASSWORD>' # kibanaserver用 docker run --rm opensearchproject/opensearch:3.8.0 \ /usr/share/opensearch/plugins/opensearch-security/tools/hash.sh -p '<KIBANASERVER_PASSWORD>' 期待される結果: それぞれ $2y$12$... で始まるbcryptハッシュ文字列が1行出力される。 次の章で使うので、2つともコピーしておく。 --- 7. 設定ファイルを作成する internal_users.yml admin と kibanaserver の2ユーザーを定義する。 kibanaserver が無いと、Dashboardsが OpenSearch Dashboards server is not ready yet と表示したまま起動しない。 :::note warn <ADMIN_HASH> と <KIBANASERVER_HASH> は、前章で生成した $2y$12$... の文字列に必ず置き換えてから実行すること。 ハッシュ値には $ が含まれるため、シェル展開を防ぐ <<'EOF'(クォート付き)のヒアドキュメントで書き込んでいる。 ::: 【log-server】 tee /opt/log-stack/internal_users.yml > /dev/null <<'EOF' --- _meta: type: "internalusers" config_version: 2 admin: hash: "<ADMIN_HASH>" reserved: true backend_roles: - "admin" description: "Admin user" kibanaserver: hash: "<KIBANASERVER_HASH>" reserved: true description: "OpenSearch Dashboards internal user" EOF chmod 644 /opt/log-stack/internal_users.yml プレースホルダーや不可視文字が残っていないか確認する。 【log-server】 cat -A /opt/log-stack/internal_users.yml | grep -A1 "^admin:\|^kibanaserver:" 期待される結果: hash: "$2y$12$..."$ のように、$2y$12$ から始まる文字列がそのまま表示されること。 M-c のような文字化けや、<ADMIN_HASH> の文字が残っていないこと。 opensearch.yml 参考: Security configuration | OpenSearch Documentation admin_dn と nodes_dn には、5章で取得したDN文字列(subject= を除いた部分)を書く。 【log-server】 tee /opt/log-stack/opensearch.yml > /dev/null <<'EOF' cluster.name: example-log-cluster network.host: 0.0.0.0 discovery.type: single-node plugins.security.disabled: false plugins.security.restapi.roles_enabled: ["all_access"] plugins.security.ssl.transport.pemcert_filepath: node.pem plugins.security.ssl.transport.pemkey_filepath: node-key.pem plugins.security.ssl.transport.pemtrustedcas_filepath: root-ca.pem plugins.security.ssl.transport.enforce_hostname_verification: false plugins.security.ssl.http.enabled: true plugins.security.ssl.http.pemcert_filepath: node.pem plugins.security.ssl.http.pemkey_filepath: node-key.pem plugins.security.ssl.http.pemtrustedcas_filepath: root-ca.pem plugins.security.authcz.admin_dn: - CN=admin,O=Example,ST=Tokyo,C=JP plugins.security.nodes_dn: - CN=<LOG_SERVER_IP>,O=Example,ST=Tokyo,C=JP plugins.security.allow_default_init_securityindex: true EOF plugins.security.restapi.roles_enabled は省略しやすいが必須である。 これが無いと、admin が all_access ロールを持っていても、セキュリティ管理用のREST API(ロールやユーザーの作成)が Role based access not enabled で拒否される。 次の章以降でFluentd用のユーザーをAPIで作成するため、この設定が欠かせない。 参考: Cannot see Security Dashboard tab in Opensearch Dashboards under plugins - OpenSearch Forum opensearch_dashboards.yml 独自の opensearch_dashboards.yml をマウントすると、イメージ内蔵のデフォルト設定は丸ごと置き換わる。 そのため、Dashboardsが内部通信に使う kibanaserver の認証情報も自分で書く必要がある。 csp.rules は、Dashboardsのセキュリティ管理画面がインラインスクリプトをブロックされて Application Not Found と表示される問題への対策である。 【log-server】 tee /opt/log-stack/opensearch_dashboards.yml > /dev/null <<'EOF' server.host: "0.0.0.0" opensearch.hosts: ["https://opensearch:9200"] opensearch.ssl.verificationMode: none opensearch.username: "kibanaserver" opensearch.password: "<KIBANASERVER_PASSWORD>" csp.rules: - "script-src 'unsafe-eval' 'self' 'unsafe-inline'" opensearch.requestHeadersAllowlist: ["authorization", "securitytenant"] opensearch_security.multitenancy.enabled: true opensearch_security.multitenancy.tenants.preferred: ["Global", "Private"] EOF 末尾の3行は、マルチテナント(テナント機能)を有効にする設定である。 テナントは、Index Patternやダッシュボードなどの保存オブジェクトを共有する範囲のことで、16章で作る閲覧専用ユーザーの権限制御に使う。 イメージ内蔵のデフォルトファイルにはこの設定が含まれているが、独自のファイルで置き換えると一緒に消え、マルチテナントは無効になる。 無効のままだと、Dashboardsの画面に Switch tenants が表示されず、16章で設定するテナントの読み取り専用権限も効かない。 | 設定 | 役割 | | --- | --- | | opensearch.requestHeadersAllowlist | どのテナントを見ているかを示す securitytenant ヘッダーを、DashboardsからOpenSearchへ渡す。これが無いとテナントを切り替えても反映されない | | opensearch_security.multitenancy.enabled | テナント機能を有効にする | | opensearch_security.multitenancy.tenants.preferred | ログイン時に表示されるテナントの並び順 | :::note info opensearch.ssl.verificationMode: none は、DashboardsからOpenSearchへの内部通信で証明書を検証しない設定である。 両者は同じDockerネットワーク内で通信するため実害は小さいが、より厳密にしたい場合は opensearch.ssl.certificateAuthorities にCA証明書を指定し、verificationMode: full にする。 ::: docker-compose.yml OpenSearchのデータは、Dockerの名前付きボリュームではなく、容量に余裕のあるディスク上のディレクトリにbind mountする。 名前付きボリュームは既定で /var/lib/docker/ 配下、つまりルートファイルシステム上に作られるため、ログの蓄積とともに容量不足になりやすい。 本記事では、大容量ディスクを /data にマウントしている想定とする。 OpenSearchのコンテナはUID/GID 1000で動作するため、データディレクトリの所有者を合わせておく。 【log-server】 sudo mkdir -p /data/opensearch sudo chown -R 1000:1000 /data/opensearch 【log-server】 tee /opt/log-stack/docker-compose.yml > /dev/null <<'EOF' services: opensearch: image: opensearchproject/opensearch:3.8.0 container_name: opensearch environment: - discovery.type=single-node - bootstrap.memory_lock=true - "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g" ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 volumes: - /data/opensearch:/usr/share/opensearch/data - ./certs/root-ca.pem:/usr/share/opensearch/config/root-ca.pem - ./certs/node.pem:/usr/share/opensearch/config/node.pem - ./certs/node-key.pem:/usr/share/opensearch/config/node-key.pem - ./certs/admin.pem:/usr/share/opensearch/config/admin.pem - ./certs/admin-key.pem:/usr/share/opensearch/config/admin-key.pem - ./opensearch.yml:/usr/share/opensearch/config/opensearch.yml - ./internal_users.yml:/usr/share/opensearch/config/opensearch-security/internal_users.yml ports: - "9200:9200" networks: - log-net restart: unless-stopped dashboards: image: opensearchproject/opensearch-dashboards:3.8.0 container_name: dashboards environment: - 'OPENSEARCH_HOSTS=["https://opensearch:9200"]' volumes: - ./certs/root-ca.pem:/usr/share/opensearch-dashboards/config/root-ca.pem - ./opensearch_dashboards.yml:/usr/share/opensearch-dashboards/config/opensearch_dashboards.yml ports: - "127.0.0.1:5601:5601" networks: - log-net depends_on: - opensearch restart: unless-stopped networks: log-net: driver: bridge EOF -Xms2g -Xmx2g はJVMのヒープサイズである。 サーバーのメモリ量の半分程度を上限の目安に調整する。 書き込んだ直後に、internal_users.yml と opensearch_dashboards.yml のマウント行が含まれているか確認する。 この確認を怠ると、ファイルを正しく編集してもコンテナに渡らず、「何度直しても認証が通らない」という分かりにくい不具合の原因になる。 【log-server】 grep -E "internal_users.yml|opensearch_dashboards.yml" /opt/log-stack/docker-compose.yml 期待される結果: 2行が表示されること。 --- 8. OpenSearchを起動して疎通を確認する 【log-server】 cd /opt/log-stack docker compose up -d docker compose logs -f opensearch "message":"started" のようなログが出たら Ctrl+C で抜ける。 管理者でのログイン確認 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' https://127.0.0.1:9200 期待される結果: "cluster_name" : "example-log-cluster" を含むJSONが返ること。 -k は証明書の検証を省略するオプションである。 証明書には 127.0.0.1 ではなくグローバルIPが入っているため、ログ集約サーバー上で 127.0.0.1 宛てに確認する場合は -k を付ける。 セキュリティ管理APIの確認 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' https://127.0.0.1:9200/_plugins/_security/api/securityconfig 期待される結果: FORBIDDEN ではなく、authc などの設定を含むJSONが返ること。 Role based access not enabled が返る場合は、opensearch.yml の plugins.security.restapi.roles_enabled が反映されていない。 Unauthorizedが返る場合の切り分け ハッシュの貼り間違いやマウント漏れなど、原因の候補が複数あるため、管理者証明書を使ってパスワードに頼らずに中身を直接確認するのが確実である。 【log-server】 docker exec -it opensearch curl -s -k \ --cacert /usr/share/opensearch/config/root-ca.pem \ --cert /usr/share/opensearch/config/admin.pem \ --key /usr/share/opensearch/config/admin-key.pem \ https://localhost:9200/_plugins/_security/api/internalusers/admin "description" が "Admin user" になっていれば、自分で用意した internal_users.yml が読み込まれている。 "Demo admin user" のような覚えのない値が返る場合は、internal_users.yml がコンテナに渡っていない。 :::note warn OpenSearchのセキュリティ設定(.opendistro_security インデックス)は、初回起動時に一度だけ internal_users.yml から初期化される。 初回起動後に internal_users.yml を修正しても、コンテナの再起動だけでは反映されない。 構築中にやり直す場合は、docker compose down のあと /data/opensearch の中身を削除してから起動し直す。 ::: --- 9. Fluentd専用の書き込みユーザーを作成する Fluentdには admin を使わせず、ログの書き込みだけができる専用ユーザー fluentd を作成する。 DashboardsのGUIからも作成できるが、本記事では手順の再現性を優先してREST APIで作成する。 参考: Getting started with OpenSearch security | OpenSearch Documentation ロールの作成 後で任意で追加する logs-system-* と logs-sysstat-* の分も、最初からまとめて許可しておく。 使わないインデックスパターンが含まれていても害はない。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \ "https://127.0.0.1:9200/_plugins/_security/api/roles/fluentd_writer" \ -H 'Content-Type: application/json' \ -d '{ "cluster_permissions": ["cluster_monitor", "indices:data/write/bulk"], "index_permissions": [ { "index_patterns": ["logs-app-*", "logs-system-*", "logs-sysstat-*"], "allowed_actions": ["create_index", "write", "indices:admin/mapping/put"] } ] }' 期待される結果: "status":"CREATED" を含むJSONが返ること。 Cluster permissionsの2つは、どちらも欠けると書き込みが失敗する。 | 権限 | 必要な理由 | | --- | --- | | cluster_monitor | fluent-plugin-opensearchは接続時にOpenSearchのバージョンを確認する。この確認に必要な cluster:monitor/main が無いと 403 security_exception でリトライし続け、書き込みまで到達しない | | indices:data/write/bulk | ログの書き込みは内部的にこのアクションとして処理され、まずクラスタレベルで許可チェックが行われる。これが無いと、Index permissions側で write を付けていても 403 になる | 2つ目が欠けている場合、docker logs opensearch に No cluster-level perm match for User ... [Action [indices:data/write/bulk]] という形で記録される。 ユーザーの作成 パスワードは、大文字、小文字、数字、記号を含む十分に長いものにする。 弱いパスワードはAPI側で拒否される。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \ "https://127.0.0.1:9200/_plugins/_security/api/internalusers/fluentd" \ -H 'Content-Type: application/json' \ -d '{"password": "<FLUENTD_PASSWORD>"}' ロールとユーザーの紐付け 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \ "https://127.0.0.1:9200/_plugins/_security/api/rolesmapping/fluentd_writer" \ -H 'Content-Type: application/json' \ -d '{"users": ["fluentd"]}' 動作確認 認証とロールの紐付けを確認する。 【log-server】 curl -k -u 'fluentd:<FLUENTD_PASSWORD>' https://127.0.0.1:9200/_plugins/_security/api/account 期待される結果: "roles":["own_index","fluentd_writer"] を含むJSONが返ること。 own_index は全ユーザーに自動付与される既定のロールなので気にしなくてよい。 :::note info https://127.0.0.1:9200(ルートパス)に fluentd で接続すると、パスワードが合っていても 403 が返ることがある。 これは認証失敗ではなく、権限が書き込みに絞られているための正常な挙動である。 401(認証失敗)と403(権限不足)を混同しないこと。 ::: 続けて、テスト用ドキュメントを1件書き込み、書き込み権限を確認する。 【log-server】 curl -k -u 'fluentd:<FLUENTD_PASSWORD>' -X POST \ "https://127.0.0.1:9200/logs-app-test/_doc" \ -H 'Content-Type: application/json' \ -d '{"message": "connectivity test"}' 期待される結果: "result":"created" を含むJSONが返ること。 確認できたら、テスト用のインデックスを削除する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X DELETE "https://127.0.0.1:9200/logs-app-test" --- 10. 送信元サーバーにFluentdを導入する 参考: Install by .deb Package (Debian/Ubuntu) | Fluentd インストールスクリプトはOSのバージョンごとに異なる。 誤ったスクリプトを実行すると、依存パッケージのバージョンが合わず Unable to correct problems, you have held broken packages で失敗する。 最初にOSのコードネームを確認する。 【app01 / app02 / app03(それぞれで実行)】 lsb_release -cs jammy(22.04)の場合は以下を実行する。 【jammyのサーバー】 curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-jammy-fluent-package6-lts.sh | sh sudo /opt/fluent/bin/fluent-gem install fluent-plugin-systemd fluent-plugin-opensearch focal(20.04)の場合は以下を実行する。 fluent-package 6はfocal向けに提供されていないため、5 LTSを使う。 インストール先(/opt/fluent/bin)は共通なので、以降の手順に違いはない。 【focalのサーバー】 curl -fsSL https://fluentd.cdn.cncf.io/sh/install-ubuntu-focal-fluent-package5-lts.sh | sh sudo /opt/fluent/bin/fluent-gem install fluent-plugin-systemd fluent-plugin-opensearch インストール中に Pending kernel upgrade のようなダイアログが出ることがある。 カーネル更新に関する別件の通知なので、<Ok> を選んで進めてよい。 実行ユーザーをsystemd-journalグループに追加する fluent-packageは _fluentd という非rootユーザーでサービスを実行する。 journaldを読み取るには systemd-journal グループへの所属が必要だが、インストール直後は所属していない。 【app01 / app02 / app03(それぞれで実行)】 sudo usermod -aG systemd-journal _fluentd id _fluentd 期待される結果: groups= の中に systemd-journal が含まれること。 :::note warn この設定を忘れると、Fluentdはエラーを一切出さずに、journaldから何も読めないまま動き続ける。 気づきにくい不具合なので、必ず先に設定しておくこと。 ::: --- 11. 送信元サーバーにCA証明書を配布する Fluentdがログ集約サーバーの証明書を検証できるよう、ルートCA証明書(root-ca.pem)を各送信元サーバーに配置する。 配布するのは公開鍵側の root-ca.pem だけであり、root-ca-key.pem などの秘密鍵は絶対にコピーしないこと。 【app01 / app02 / app03(それぞれで実行)】 sudo mkdir -p /etc/fluent/certs scp -P <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP>:/opt/log-stack/certs/root-ca.pem /tmp/root-ca.pem sudo mv /tmp/root-ca.pem /etc/fluent/certs/root-ca.pem sudo chmod 644 /etc/fluent/certs/root-ca.pem ログ集約サーバーへのSSHが踏み台サーバー経由に限られている場合は、-o ProxyJump=<YOUR_USER>@<BASTION_IP>:<BASTION_SSH_PORT> を付けて多段接続する。 証明書が使えるか、送信元サーバーから確認しておく。 ここでは -k を付けず、配布したCA証明書で検証する。 【app01 / app02 / app03(それぞれで実行)】 curl --cacert /etc/fluent/certs/root-ca.pem -u 'fluentd:<FLUENTD_PASSWORD>' \ https://<LOG_SERVER_IP>:9200/_plugins/_security/api/account 期待される結果: fluentd_writer を含むJSONが返ること。 SSL: no alternative certificate subject name matches のようなエラーが出る場合は、5章でノード証明書に入れたIPアドレスと、接続先のIPアドレスが一致していない。 --- 12. Fluentdの設定ファイルを作成する 設定ファイル全体を示す前に、つまずいた箇所とその理由を説明しておく。 matchesはワイルドカードに対応していない fluent-plugin-systemdには、取り込むログを絞る matches というパラメータがある。 matches [{ "_SYSTEMD_UNIT": "myapp@*.service" }] しかし matches はjournald本体のフィルタ機能をそのまま使っており、完全一致でしか比較しない。 myapp@*.service という文字列は * を含む文字列そのものとして扱われ、実際のユニット名には一致しない。 そのため本記事では matches を使わず、全ジャーナルを読み込んだうえで、後段の grep フィルタで正規表現による絞り込みを行う。 journaldの挙動は以下で確認できる。 journalctl _SYSTEMD_UNIT="myapp@*.service" -n 5 --no-pager # 0件 journalctl _SYSTEMD_UNIT="myapp@testapp.service" -n 5 --no-pager # 表示される read_from_headはfalseにする read_from_head true は、journaldに保存されている最も古いログから順に読む指定である。 長期間稼働しているサーバーには数か月から数年分の履歴が残っていることがあり、Fluentdはそれを最初から読み進める。 その間は新しいログに到達できず、エラーも出ないまま何も送信されていないように見える。 導入時点から先のログを集めれば十分なので、false にする。 日付入りのインデックス名には <buffer time> が必要 index_name logs-app-%Y.%m.%d のように日付のプレースホルダーを使う場合、バッファのチャンクキーに time を指定し、timekey を設定する必要がある。 これを忘れると、プレースホルダーが展開されず logs-app-%Y.%m.%d という名前のインデックスがそのまま作られる。 また <buffer time> にすると、既定の flush_mode が lazy(timekeyの区切りまで送信しない)に変わり、flush_interval が無視される。 数秒おきに送信させたいので、flush_mode interval を明示する。 :::note warn 運用開始後に <buffer> を <buffer time> に変更するなど、チャンクキーの構造を変えた場合は、古いバッファが残っていると unexpected error while checking flushed chunks で詰まることがある。 その場合は sudo systemctl stop fluentd のあと、/var/log/fluent/buffer/ 配下の該当ディレクトリを空にしてから起動し直す。 ::: @timestampは自分で付与する fluent-plugin-opensearchが @timestamp を自動で付けるのは、logstash_format true のときだけである。 本記事ではインデックス名を自分で指定するため logstash_format false にしており、@timestamp も付かなくなる。 @timestamp が無いとDashboardsで時刻による絞り込みができないため、record_transformer で付与する。 hostnameフィールドでサーバーを区別する record_transformer で hostname ${Socket.gethostname} を付与する。 実行中のホスト名が自動で入るため、設定ファイルは全サーバーで同一の内容のまま使える。 パスワードはダブルクォートで囲む Fluentdの設定ファイルでは、クォート無しの値に # が含まれると、それ以降がコメントとして切り捨てられることがある。 パスワードは必ずダブルクォートで囲む。 設定ファイル全体 <LOG_SERVER_IP> と <FLUENTD_PASSWORD> を自分の値に置き換えて実行する。 host には、5章でノード証明書に入れたIPアドレスと同じ値を書く。 【app01 / app02 / app03(それぞれで実行)】 sudo tee /etc/fluent/fluentd.conf > /dev/null <<'EOF' <source> @type systemd path /var/log/journal read_from_head false tag app.journal <storage> @type local persistent true path /var/log/fluent/systemd-app.pos </storage> <entry> fields_strip_underscores true </entry> </source> <filter app.journal> @type grep <regexp> key SYSTEMD_UNIT pattern /^myapp@.*\.service$/ </regexp> </filter> <filter app.journal> @type record_transformer enable_ruby true <record> container_name ${record["MESSAGE"].to_s.split("|", 2)[0].to_s.strip} log_message ${record["MESSAGE"].to_s.split("|", 2)[1].to_s.strip} "@timestamp" ${Time.at(time.to_i).strftime('%Y-%m-%dT%H:%M:%S%:z')} hostname ${Socket.gethostname} </record> </filter> <match app.journal> @type opensearch host <LOG_SERVER_IP> port 9200 scheme https ssl_verify true ca_file /etc/fluent/certs/root-ca.pem user fluentd password "<FLUENTD_PASSWORD>" index_name logs-app-%Y.%m.%d logstash_format false reconnect_on_error true reload_on_failure true <buffer time> @type file timekey 1d flush_mode interval path /var/log/fluent/buffer/opensearch-app flush_interval 5s retry_max_times 10 retry_wait 5s chunk_limit_size 8MB </buffer> </match> EOF fields_strip_underscores true により、journaldのフィールド名 _SYSTEMD_UNIT は先頭のアンダースコアが外れて SYSTEMD_UNIT になる。 grep フィルタの key は外れた後の名前に合わせてある。 参考: - fluent-plugin-systemd README - fluent-plugin-opensearch README --- 13. Fluentdを起動して到達を確認する 設定ファイルの文法を確認してから起動する。 【app01 / app02 / app03(それぞれで実行)】 sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf sudo systemctl restart fluentd sudo systemctl status fluentd 対象サービスのログを出すため、アプリケーションに何度かアクセスするか、サービスを再起動する。 数十秒待ってから、ログ集約サーバーでインデックスを確認する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/_cat/indices/logs-*?v" 期待される結果: logs-app-YYYY.MM.DD が表示されること。 1件取り出して、フィールドの中身も確認する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/logs-app-*/_search?pretty&size=1" 期待される結果: _source の中に @timestamp、hostname、container_name、log_message が含まれていること。 届かない場合の切り分け エラーが出ないのに届いていない、という状態になりやすいため、以下の順に確認する。 1. バッファディレクトリの中身 ls -la /var/log/fluent/buffer/opensearch-app/ 空のままであれば、<source> か <filter> の段階でデータが流れていない。 ファイルがあるのに送信されていなければ、<match>(OpenSearchへの送信)側の問題である。 2. position(読み取り位置)ファイルの中身 sudo cat /var/log/fluent/systemd-app.pos/worker0/storage.json {} のままであれば、journaldから1件も読めていない。 {"journal":"s=..."} のようなカーソル情報が入っていれば、読み取りは進んでいる。 3. 実行ユーザーでjournaldを読めるか sudo runuser -u _fluentd -- journalctl -u myapp@testapp.service -n 5 --no-pager 権限エラーになる場合は、10章の systemd-journal グループへの追加が反映されていない。 グループ追加後にFluentdを再起動していない場合によく起きる。 4. Fluentdのログ sudo tail -60 /var/log/fluent/fluentd.log | ログに出る内容 | 疑うべき箇所 | | --- | --- | | security_exception、403 | 9章のロールのCluster permissions | | 401、Unauthorized | fluentd.conf のパスワードと9章で設定したパスワードの不一致 | | SSLError、hostname does not match | 証明書のIPと host の不一致(5章、11章) | | unexpected error while checking flushed chunks | バッファ構造の変更後に残った古いチャンク(12章) | | has timestamp placeholders, but chunk key 'time' is not configured | <buffer time> の指定漏れ(12章) | --- 14. 古いインデックスを自動削除する(ISM) ログは日ごとにインデックスが増え続けるため、Index State Management(ISM)で一定期間後に自動削除する。 logs-app-* は、7日経過で読み取り専用(warm)にし、31日経過で削除する。 参考: ISM API | OpenSearch Documentation 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-app-policy" \ -H "Content-Type: application/json" -d '{ "policy": { "description": "app log lifecycle", "default_state": "hot", "states": [ { "name": "hot", "actions": [], "transitions": [{ "state_name": "warm", "conditions": { "min_index_age": "7d" } }] }, { "name": "warm", "actions": [{ "read_only": {} }], "transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "31d" } }] }, { "name": "delete", "actions": [{ "delete": {} }] } ], "ism_template": { "index_patterns": ["logs-app-*"], "priority": 100 } } }' ism_template を含めておくと、パターンに一致するインデックスが新しく作られたときに自動でポリシーが適用される。 既存のインデックスには自動適用されない ism_template が効くのは、ポリシー作成後に新しく作られたインデックスだけである。 13章ですでに作られた当日分のインデックスには適用されていないため、確認して手動で適用する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' "https://127.0.0.1:9200/_plugins/_ism/explain/logs-app-*?pretty" "policy_id" : null であれば未適用なので、以下で適用する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X POST \ "https://127.0.0.1:9200/_plugins/_ism/add/logs-app-*" \ -H 'Content-Type: application/json' \ -d '{"policy_id": "logs-app-policy"}' 再度 _ism/explain を実行し、"policy_id" : "logs-app-policy" になっていることを確認する。 --- 15. Dashboardsでログを確認する ダッシュボードの作成は本記事の範囲外とし、ここではDiscover画面でログが検索できることだけを確認する。 SSHトンネルで接続する Dashboardsは 127.0.0.1:5601 でのみ待ち受けているため、手元PCからSSHのポートフォワードで接続する。 【手元PC】 ssh -L 5601:localhost:5601 -p <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP> 踏み台サーバーを経由する場合は -J を使う。 【手元PC】 ssh -L 5601:localhost:5601 -J <YOUR_USER>@<BASTION_IP>:<BASTION_SSH_PORT> -p <SSH_PORT> <YOUR_USER>@<LOG_SERVER_IP> 接続したまま、手元PCのブラウザで http://localhost:5601 を開く。 https ではなく http である点に注意する。 ブラウザとDashboards間の通信はSSHトンネル自体で暗号化されているため、この構成では http のままで問題ない。 ログインには admin と、6章で設定したパスワードを使う。 SSHのセッションを閉じるとトンネルも切れるので、閲覧中はターミナルを開いたままにしておく。 初回ログイン時に Select your tenant という画面が表示された場合は、Global を選ぶ。 テナントは、Index Patternなどの保存オブジェクトを共有する範囲のことである。 Private を選んだまま次のIndex Patternを作成すると、作成したユーザー本人にしか見えず、16章で作る閲覧専用ユーザーからは見えなくなる。 現在のテナントは、右上のユーザーアイコンから確認、切り替えできる。 Index Patternを作成する 1. 左上のメニューから Dashboards Management を開く 2. Index Patterns を選び、Create index pattern をクリックする 3. パターンに logs-app-* と入力して Next step に進む 4. 時刻フィールドに @timestamp を選び、Create index pattern で完了する Discoverで検索する 1. 左メニューから Discover を開き、logs-app-* を選ぶ 2. 画面右上で表示期間を指定する 3. 左のフィールド一覧から hostname、container_name、log_message を追加すると、その列だけを表示できる 4. 検索バーに hostname:app01 and container_name:testapp-web-1 のように入力すると絞り込める :::note info OpenSearch Dashboards 3.x系では、Discover画面が真っ白のまま何も表示されないことがある。 その場合は、Global テナントを選んだ状態で Dashboards Management の Advanced Settings を開き、query:enhancements:enabled を false に変更する。 Advanced Settingsはテナントごとに別管理であり、16章の閲覧専用ユーザーは自分で変更できないため、必ず Global 側で設定しておく。 参考: Discover page is blank due to query enhancements · Issue #10925 ::: --- 16. 閲覧専用ユーザーを作成する ログを見るだけの利用者に admin を渡すと、ユーザーやロールの変更、インデックスの削除まで行えてしまう。 そこで、DashboardsとDiscoverでログを閲覧できるが、ログやダッシュボードの書き込み、削除はできない閲覧専用ユーザー viewer を作成する。 閲覧専用ユーザーには、次の2つのロールを付与する。 | ロール | 種類 | 役割 | | --- | --- | --- | | logs_reader | 新規作成 | logs-* 系インデックスの読み取りと、Globalテナントの読み取りだけを許可する | | kibana_user | 組み込み | Dashboardsへのログインと基本操作に必要。単体ではログデータへのアクセス権を持たない | 書き込みと削除を実際に防いでいるのは、logs_reader の権限である。 事前確認: マルチテナントが有効になっていること 7章の opensearch_dashboards.yml にマルチテナントの設定が入っていることを確認する。 【log-server】 grep -i "multitenancy\|requestHeaders" /opt/log-stack/opensearch_dashboards.yml 期待される結果: 7章で追加した3行が表示されること。 何も表示されない場合は7章の3行を追記し、docker compose restart dashboards で再起動する。 あわせて、admin でDashboardsにログインし、右上のユーザーアイコンに Switch tenants が表示されること、現在のテナントが Global であることを確認する。 15章で作成したIndex Patternと、query:enhancements:enabled の変更が Global テナント側にあることもここで確認しておく。 Private テナントで作成していた場合は、Dashboards Management の Saved objects でエクスポートし、Global に切り替えてからインポートする。 組み込みロールの名称の確認 Dashboards用の組み込みロールは、新名称(opensearch_dashboards_user)ではなく旧名称(kibana_user)で登録されていることがある。 実際の名称を確認してから進める。 【log-server】 curl -s -k -u 'admin:<ADMIN_PASSWORD>' \ "https://127.0.0.1:9200/_plugins/_security/api/roles" \ | python3 -c 'import json,sys; [print(k) for k in sorted(json.load(sys.stdin))]' 一覧に kibana_user があればそのまま進める。 kibana_user が無く opensearch_dashboards_user がある環境では、以降の kibana_user をそちらに読み替える。 読み取り用ロールの作成 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \ "https://127.0.0.1:9200/_plugins/_security/api/roles/logs_reader" \ -H 'Content-Type: application/json' \ -d '{ "cluster_permissions": ["cluster_composite_ops_ro"], "index_permissions": [ { "index_patterns": ["logs-app-*", "logs-system-*", "logs-sysstat-*"], "allowed_actions": ["read"] } ], "tenant_permissions": [ { "tenant_patterns": ["global_tenant"], "allowed_actions": ["kibana_all_read"] } ] }' 期待される結果: "status":"CREATED" を含むJSONが返ること。 | 区分 | 対象 | 権限 | | --- | --- | --- | | Cluster permissions | - | cluster_composite_ops_ro(検索系の読み取り操作のみ) | | Index permissions | logs-app-*、logs-system-*、logs-sysstat-* | read(検索と取得のみ。書き込み、削除、インデックス作成は不可) | | Tenant permissions | global_tenant | kibana_all_read(Globalテナントのダッシュボード等の閲覧のみ。保存と削除は不可) | 17章、18章を導入しない場合は、logs-system-* と logs-sysstat-* を外してもよい。 残しておいても害はない。 ユーザーの作成とロールの付与 ユーザー作成時に opendistro_security_roles を指定し、ユーザー側から直接ロールを付与する。 組み込みロールの rolesmapping を PUT すると、既存の紐付けを丸ごと置き換えてしまうためである。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT \ "https://127.0.0.1:9200/_plugins/_security/api/internalusers/viewer" \ -H 'Content-Type: application/json' \ -d '{ "password": "<VIEWER_PASSWORD>", "opendistro_security_roles": ["logs_reader", "kibana_user"], "description": "Read-only viewer" }' curl -k -u 'admin:<ADMIN_PASSWORD>' -X DELETE \ https://127.0.0.1:9200/_plugins/_security/api/cache 期待される結果: 1つ目で "status":"CREATED"、2つ目で "status":"OK" が返ること。 :::note warn キー名は opendistro_security_roles である。 opensearch_security_roles のように新しい名称に見える綴りにすると、ロールが付与されない。 ::: この方式では、DashboardsのSecurity画面にある Roles の Mapped users タブに viewer は表示されない。 付与状況は次の動作確認のAPIで確認する。 動作確認 ロールが付与されていることを確認する。 【log-server】 curl -k -u 'viewer:<VIEWER_PASSWORD>' \ https://127.0.0.1:9200/_plugins/_security/api/account 期待される結果: "roles" に logs_reader と kibana_user が含まれること。 ログインデックスへの書き込みと削除が拒否されることを確認する。 【log-server】 # 書き込み(拒否されるべき) curl -k -u 'viewer:<VIEWER_PASSWORD>' -X POST \ "https://127.0.0.1:9200/logs-app-test/_doc" \ -H 'Content-Type: application/json' -d '{"message": "should fail"}' # インデックスの削除(拒否されるべき) curl -k -u 'viewer:<VIEWER_PASSWORD>' -X DELETE \ "https://127.0.0.1:9200/logs-app-$(date +%Y.%m.%d)" 期待される結果: どちらも 403 と security_exception が返ること。 Globalテナントへの保存が拒否されることを、DashboardsのAPIで確認する。 ここではテスト用のIndex Patternを作成しようとして、拒否されることを見る。 【log-server】 curl -s -u 'viewer:<VIEWER_PASSWORD>' -X POST \ -H 'securitytenant: global' \ -H 'osd-xsrf: true' \ -H 'Content-Type: application/json' \ "http://127.0.0.1:5601/api/saved_objects/index-pattern/viewer-write-test" \ -d '{"attributes": {"title": "viewer-write-test-*"}}' 期待される結果: 403 のエラーが返ること。 "id":"viewer-write-test" を含むJSONが返った場合は保存できてしまっているので、logs_reader のTenant permissionsとマルチテナントの設定を見直し、作成されたオブジェクトを admin で削除する。 最後にDashboardsの画面で確認する。 1. 15章と同じ手順でSSHトンネルを張り、一度ログアウトしてから viewer でログインする 2. テナントの選択画面が表示されたら Global を選ぶ 3. 左メニューに Discover が表示され、logs-app-* のログが閲覧できること(真っ白にならないこと) 4. 左メニューに Security が表示されないこと 設計上の注意点 - 画面上には Edit や Save のボタンが表示されるが、押してもGlobalテナントへの保存は 403 で拒否され、反映されない。見た目は編集できそうに見えても、実際には何も変更できない状態である。 - Dashboardsの読み取り専用モード(opensearch_security.readonly_mode.roles)は使わない。このモードは編集ボタンを隠すだけでなく、Discoverなどダッシュボード以外のアプリもメニューから隠してしまうため、Discoverでログを見るという目的を満たせない。 - viewer が閲覧できるのはGlobalテナントの保存オブジェクトだけである。今後 admin でIndex Patternやダッシュボードを作る際は、必ず Global テナントを選んだ状態で作成する。 - viewer 自身の Private テナントへの保存はできるが、他のユーザーやGlobalテナント、ログデータには影響しない。 - admin は all_access のままなので、閲覧や操作の範囲は変わらない。マルチテナントの有効化により、ログイン時にテナントを選ぶ手順が加わる点だけが変わる。 ここまでで、ログの収集と閲覧の基盤は完成である。 以降の2章は、必要に応じて追加する任意の手順である。 --- 17. (任意)システムログ全体を収集する 12章の設定は、myapp@*.service のログだけを収集する。 カーネルログや他のサービスのログなど、OS全体のジャーナルも収集したい場合は、別のパイプラインとして追加する。 既存のパイプラインに混ぜないのは、ログ量と保持期間の要件が異なるためである。 アプリケーションログは障害調査で頻繁に参照するが、OS全体のログは量が多く、参照頻度は低い。 そこで別インデックス logs-system-* に格納し、保持期間も短くする。 fluentd.confへの追記 既存のブロックは変更せず、末尾に追記する。 grep フィルタは入れず、全件を対象にする。 バッファの格納先も別ディレクトリにする。 【app01 / app02 / app03(それぞれで実行)】 sudo tee -a /etc/fluent/fluentd.conf > /dev/null <<'EOF' <source> @type systemd path /var/log/journal read_from_head false tag system.journal <storage> @type local persistent true path /var/log/fluent/systemd-system.pos </storage> <entry> fields_strip_underscores true </entry> </source> <filter system.journal> @type record_transformer enable_ruby true <record> "@timestamp" ${Time.at(time.to_i).strftime('%Y-%m-%dT%H:%M:%S%:z')} hostname ${Socket.gethostname} </record> </filter> <match system.journal> @type opensearch host <LOG_SERVER_IP> port 9200 scheme https ssl_verify true ca_file /etc/fluent/certs/root-ca.pem user fluentd password "<FLUENTD_PASSWORD>" index_name logs-system-%Y.%m.%d logstash_format false reconnect_on_error true reload_on_failure true <buffer time> @type file timekey 1d flush_mode interval path /var/log/fluent/buffer/opensearch-system flush_interval 5s retry_max_times 10 retry_wait 5s chunk_limit_size 8MB </buffer> </match> EOF sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf sudo systemctl restart fluentd 書き込み権限は9章のロールですでに許可済みである。 ISMポリシー warmを挟まず、7日で削除する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-system-policy" \ -H "Content-Type: application/json" -d '{ "policy": { "description": "system journal log lifecycle", "default_state": "hot", "states": [ { "name": "hot", "actions": [], "transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "7d" } }] }, { "name": "delete", "actions": [{ "delete": {} }] } ], "ism_template": { "index_patterns": ["logs-system-*"], "priority": 100 } } }' 既存インデックスへの適用確認は14章と同じ手順で行う。 Dashboardsで閲覧する場合は、15章と同じ要領で logs-system-* のIndex Patternを作成する。 :::note info 全ジャーナルを対象にするとログ量が大きく増える。 いきなり全台に入れるのではなく、1台で1〜2週間ほど試験運用し、ディスク使用量の増え方を見てから広げると安全である。 ::: --- 18. (任意)sysstatのリソース使用状況を取り込む CPU、メモリ、ディスクI/Oの使用状況を、ログと同じOpenSearch上で確認できるようにする。 sysstatは常時ログを出力するサービスではなく、sar で問い合わせるとその時点の値を返すツールである。 そこで、Fluentdの exec 入力プラグインで1分おきにスクリプトを実行し、結果を取り込む。 sysstatの導入 【app01 / app02 / app03(それぞれで実行)】 sudo apt update sudo apt install -y sysstat sudo sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat sudo systemctl enable --now sysstat sar -u 1 1 期待される結果: CPU使用率のレポートが表示されること。 出力形式の確認 sadf -j(JSON出力)のキー名は、sysstatのバージョンによって異なることがある。 スクリプトを作る前に、自分の環境での出力を確認しておく。 LC_ALL=C sadf -j -- -u -r -d 1 1 LC_ALL=C は必ず付ける。 ロケールによっては小数点が , で出力され、JSONとして不正な形式になる既知の不具合があるためである。 参考: sadf produces invalid JSON output: Floats with comma instead of dot · Issue #128 出力の中に cpu-load、memory、disk のキーがあることを確認する。 Ubuntu 20.04と22.04では、cpu-load の user system idle と、memory の memused-percent memused は共通だった。 一方、disk のデバイス名は、22.04では vda のような名前、20.04では dev252-0 のような番号形式になることがある。 次のスクリプトは disk の配列をそのまま格納するため影響はないが、デバイス名で絞り込む際は注意する。 変換スクリプトの作成 sadf -j の出力は複数行にまたがる整形済みJSONであり、Fluentdの行単位の読み取りと相性が悪い。 そこで、必要な値だけを抜き出して1行のJSONにまとめるスクリプトを用意する。 また、sar は2回サンプリングして初めてその間の差分を計算できる。 1回だけのサンプリングでは値がほぼ空になるため、sar -o で2回分を一時ファイルに記録してから sadf で読む。 【app01 / app02 / app03(それぞれで実行)】 sudo mkdir -p /opt/fluent-scripts sudo tee /opt/fluent-scripts/sysstat_collect.py > /dev/null <<'EOF' #!/usr/bin/env python3 import subprocess import json import socket import datetime import os import tempfile env = os.environ.copy() env["LC_ALL"] = "C" with tempfile.NamedTemporaryFile(prefix="sysstat_", delete=True) as tmp: # 1秒間隔で2回、一時ファイルにライブ測定を記録する subprocess.check_call( ["sar", "-o", tmp.name, "1", "2"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env, ) raw = subprocess.check_output( ["sadf", "-j", "--", "-u", "-r", "-d", tmp.name], text=True, env=env, ) data = json.loads(raw) # 最後の要素が実際の差分データ stat = data["sysstat"]["hosts"][0]["statistics"][-1] # キー名は「出力形式の確認」の結果に合わせて調整する cpu = stat.get("cpu-load", [{}])[0] mem = stat.get("memory", {}) disks = stat.get("disk", []) record = { "@timestamp": datetime.datetime.now().astimezone().isoformat(timespec="seconds"), "hostname": socket.gethostname(), "cpu_user": cpu.get("user"), "cpu_system": cpu.get("system"), "cpu_idle": cpu.get("idle"), "mem_used_percent": mem.get("memused-percent"), "mem_used_kb": mem.get("memused"), "disks": disks, } print(json.dumps(record, ensure_ascii=False)) EOF sudo chmod 755 /opt/fluent-scripts/sysstat_collect.py Fluentdの実行ユーザー _fluentd で正しく動くか確認する。 sudo runuser -u _fluentd -- /opt/fluent-scripts/sysstat_collect.py 期待される結果: {"@timestamp": "...", "hostname": "...", "cpu_user": ..., ...} のような改行を含まない1行のJSONが出力されること。 何度か実行して cpu_user などの値が変動していれば、ライブ測定が正しく行われている。 :::note warn スクリプトをホームディレクトリ配下(例: /home/<YOUR_USER>/scripts/)に置く場合は注意が必要である。 Ubuntuのホームディレクトリは 750 になっていることが多く、_fluentd はスクリプト自体の権限が正しくても手前のディレクトリを通過できず Permission denied になる。 本記事で /opt/fluent-scripts に置いているのはこのためである。 ホームディレクトリに置く必要がある場合は、sudo chmod o+x /home/<YOUR_USER> で通過権限だけを追加する。 ::: fluentd.confへの追記 【app01 / app02 / app03(それぞれで実行)】 sudo tee -a /etc/fluent/fluentd.conf > /dev/null <<'EOF' <source> @type exec tag sysstat.metrics command /opt/fluent-scripts/sysstat_collect.py run_interval 60s <parse> @type json </parse> </source> <match sysstat.metrics> @type opensearch host <LOG_SERVER_IP> port 9200 scheme https ssl_verify true ca_file /etc/fluent/certs/root-ca.pem user fluentd password "<FLUENTD_PASSWORD>" index_name logs-sysstat-%Y.%m.%d logstash_format false reconnect_on_error true reload_on_failure true <buffer time> @type file timekey 1d flush_mode interval path /var/log/fluent/buffer/opensearch-sysstat flush_interval 5s retry_max_times 10 retry_wait 5s chunk_limit_size 8MB </buffer> </match> EOF sudo /opt/fluent/bin/fluentd --dry-run -c /etc/fluent/fluentd.conf sudo systemctl restart fluentd run_interval 60s により、1分おきにスクリプトが実行される。 書き込み権限は9章のロールですでに許可済みである。 ISMポリシー 1分おきに継続してデータが増えるため、14日で削除する。 【log-server】 curl -k -u 'admin:<ADMIN_PASSWORD>' -X PUT "https://127.0.0.1:9200/_plugins/_ism/policies/logs-sysstat-policy" \ -H "Content-Type: application/json" -d '{ "policy": { "description": "sysstat metrics lifecycle", "default_state": "hot", "states": [ { "name": "hot", "actions": [], "transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "14d" } }] }, { "name": "delete", "actions": [{ "delete": {} }] } ], "ism_template": { "index_patterns": ["logs-sysstat-*"], "priority": 100 } } }' 既存インデックスへの適用確認は14章と同じ手順で行う。 --- 19. 構築後の確認項目 | 確認内容 | 確認方法 | | --- | --- | | OpenSearchとDashboardsが起動している | 【log-server】docker compose ps で両方が Up | | 9200番が送信元IP以外から遮断されている | 【log-server】sudo iptables -L DOCKER-USER -n -v に DROP の行がある | | 第三者のIPから9200番に接続できない | 送信元以外の回線から curl -k https://<LOG_SERVER_IP>:9200 がタイムアウトする | | 各送信元でFluentdが稼働している | 【各送信元】systemctl status fluentd がエラーなく active | | 全サーバー分のログが届いている | Discoverで hostname ごとに件数がある | | 閲覧専用ユーザーで検索だけができる | 【log-server】viewer で _search が成功し、_doc への書き込みが 403 になる | | ISMポリシーが適用されている | 【log-server】_plugins/_ism/explain/logs-* で policy_id が入っている | --- 運用上の注意点 - Docker公開ポートのアクセス制御は、ufwではなくDOCKER-USERチェーンで行う。Dockerの更新やサーバー再起動のあとは iptables -L DOCKER-USER -n -v で再確認する。 - 送信元サーバーのグローバルIPが変わると、ログが届かなくなる。サーバーを追加、移設した際は /etc/ufw/after.rules の更新を忘れないようにする。 - 証明書の有効期限(本記事では3650日)を管理台帳などに記録しておく。 - root-ca-key.pem を持っていれば任意の証明書を発行できてしまう。ログ集約サーバーの外には出さず、バックアップも厳重に扱う。 - kibanaserver のパスワードは opensearch_dashboards.yml に平文で書かれる。/opt/log-stack のディレクトリの権限は、管理者以外が読めないようにしておく。 - ログを閲覧するだけの利用者には admin を渡さず、16章の viewer のような閲覧専用ユーザーを使う。利用者ごとにユーザーを分けておくと、退職や異動の際にそのユーザーだけを削除できる。 - セキュリティ設定は初回起動時にしか internal_users.yml から初期化されない。運用開始後のユーザー追加やパスワード変更は、REST APIかDashboardsの Security 画面から行う。 - DashboardsのGUIでロールのIndex permissionsを編集する場合、Document level securityの欄を空文字列のまま確定しないこと。空文字列が保存されると、そのインデックスパターンへの権限判定全体が無効になる現象を3.8.0で確認している。誤って保存した場合は、API(GET _plugins/_security/api/roles/<ロール名>)で定義を確認し、dls キーを含まない形でPUTし直す。 参考文献 | 項目 | 参照先 | | --- | --- | | Docker公式インストール手順 | docs.docker.com | | DockerがUFWをバイパスする問題 | for-linux#777 | | DOCKER-USERチェーンによる対策 | chaifeng/ufw-docker | | OpenSearchの重要なシステム設定 | docs.opensearch.org | | OpenSearchのDocker構築 | docs.opensearch.org | | 自己署名証明書の生成 | docs.opensearch.org | | Security設定全般 | docs.opensearch.org | | internal_users.ymlとhash.sh | docs.opensearch.org | | デモ設定インストーラーがスキップされる件 | forum.opensearch.org | | restapi.roles_enabledが必要な件 | forum.opensearch.org | | ISM API | docs.opensearch.org | | fluent-packageのインストール | docs.fluentd.org | | fluent-plugin-systemd | fluent-plugins-nursery/fluent-plugin-systemd | | fluent-plugin-opensearch | fluent/fluent-plugin-opensearch | | sadfのJSON小数点の不具合 | sysstat#128 | | Discover画面が真っ白になる件 | OpenSearch-Dashboards#10925 |
Which data sources would you like to see supported in #OpenSearch Dashboards? Anything you'd like to see added, besides the recently-added @PrometheusIO? chime in with your comments here or on the GitHub issue OpenSearch-Dashboards#12819 #OpenSearchAmbassador #observability t.co
Which data sources would you like to see supported in #OpenSearch Dashboards? Anything you'd like to see added, besides the recently-added @prometheus.io ? chime in with your comments here or on the GitHub issue OpenSearch-Dashboards#12819 #OpenSearchAmbassador #dashboards #observability
Opensearch scalability is a big challenge which we have been facing as well. Our team is exploring cloud native architecture which has been nicely captured in this RFC (OpenSearch#17957). Will be a good weekend read, will share my learnings.