みんなでOpenSearchを育てよう!調査からOSSへのPRまでの実録ガイド
はじめに
OpenSearchはスケーラブルで多様なユースケースに対応できるオープンソースの検索エンジンです。オープンソースなので、誰でもソースコードを見ることができて、誰でも改善に貢献できます。
しかし、いざコントリビューションに挑戦するとなると、何から始めたものか悩む方も少なくないのではないでしょうか。そこで、先日私が作成したPull Request「Fix NPE in validateSearchableSnapshotRestorable when shard size is unavailable」を例に、OpenSearchへの貢献の流れをご紹介しようと思います。
バグの発見
ある日、Searchable snapshotsの挙動を調査していると、奇妙なエラーに遭遇しました。
Searchable Snapshotをリストアする際に、NullPointerExceptionが発生してリストアに失敗するのです。しかしながら、当初はエラーが発生する原因がわからず、一見すると同じ操作をしているにも関わらずエラーが発生する時としない時がありました。
# Searchable Snapshotを使用してスナップショットをリストア
POST _snapshot/my_repository/snapshot_all/_restore
{
"indices": "test-index",
"storage_type": "remote_snapshot",
"rename_pattern": "(.+)",
"rename_replacement": "remote5_$1"
}
Dev Tool上のレスポンス
# POST _snapshot/my_repository/snapshot_all/_restore
{
"error": {
"root_cause": [
{
"type": "null_pointer_exception",
"reason": null
}
],
"type": "null_pointer_exception",
"reason": null
},
"status": 500
}
ノードのエラーログ
java.lang.NullPointerException: null
at java.base/java.util.stream.ReferencePipeline$5$1.accept(ReferencePipeline.java:249) ~[?:?]
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:215) ~[?:?]
at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1716) ~[?:?]
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:570) ~[?:?]
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:560) ~[?:?]
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921) ~[?:?]
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:265) ~[?:?]
at java.base/java.util.stream.LongPipeline.reduce(LongPipeline.java:502) ~[?:?]
at java.base/java.util.stream.LongPipeline.sum(LongPipeline.java:460) ~[?:?]
at org.opensearch.snapshots.RestoreService$1.validateSearchableSnapshotRestorable(RestoreService.java:916) ~[opensearch-3.3.2.jar:3.3.2]
at org.opensearch.snapshots.RestoreService$1.execute(RestoreService.java:588) ~[opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.ClusterStateUpdateTask.execute(ClusterStateUpdateTask.java:67) ~[opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.service.ClusterManagerService.executeTasks(ClusterManagerService.java:890) ~[opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.service.ClusterManagerService.calculateTaskOutputs(ClusterManagerService.java:441) ~[opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.service.ClusterManagerService.runTasks(ClusterManagerService.java:301) [opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.service.ClusterManagerService$Batcher.run(ClusterManagerService.java:214) [opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.service.TaskBatcher.runIfNotProcessed(TaskBatcher.java:206) [opensearch-3.3.2.jar:3.3.2]
at org.opensearch.cluster.service.TaskBatcher$BatchedTask.run(TaskBatcher.java:264) [opensearch-3.3.2.jar:3.3.2]
at org.opensearch.common.util.concurrent.ThreadContext$ContextPreservingRunnable.run(ThreadContext.java:916) [opensearch-3.3.2.jar:3.3.2]
at org.opensearch.common.util.concurrent.PrioritizedOpenSearchThreadPoolExecutor$TieBreakingPrioritizedRunnable.runAndClean(PrioritizedOpenSearchThreadPoolExecutor.java:299) [opensearch-3.3.2.jar:3.3.2]
at org.opensearch.common.util.concurrent.PrioritizedOpenSearchThreadPoolExecutor$TieBreakingPrioritizedRunnable.run(PrioritizedOpenSearchThreadPoolExecutor.java:262) [opensearch-3.3.2.jar:3.3.2]
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1095) [?:?]
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:619) [?:?]
at java.base/java.lang.Thread.run(Thread.java:1447) [?:?]
調査
類似の報告を探す
エラーログで検索してみると、類似の事象を報告しているissueが見つかりました。
2名が同じエラーを報告しており、それぞれ状況が少し異なるものの、2人とも"storage_type": "remote_snapshot"を設定してPOST /_snapshot/<repository>/<snapshot>/_restoreした際にNullPointerExceptionが発生したということでした。
ソースコードを読んでみる
エラーログを読む限り、Exceptionが発生している箇所はorg.opensearch.snapshots.RestoreService$1.validateSearchableSnapshotRestorable(RestoreService.java:916のようです。
それではRestoreService.javaの該当箇所を読んでみましょう。どうやら、totalRestoredRemoteIndexesSizeを計算する際に問題が起きているようです。
validateSearchableSnapshotRestorableメソッド全体を読んだところ、これはSearchable Snapshotをリストアする際に、ローカルのキャッシュ領域のサイズが十分に確保できていることを確認する処理であることが分かりました。
リストア対象のインデックスのサイズが、totalNodeFileCacheSize(ノードのキャッシュ領域のサイズ) * remoteDataToFileCacheRatio(ノードのキャッシュ領域のサイズに対して許容するSearchable Snapshotのサイズの割合)を下回っていることを確認しているのです。
ちなみにDATA_TO_FILE_CACHE_SIZE_RATIO_SETTINGのデフォルト値は5.0であるため、デフォルトではtotalNodeFileCacheSizeの5倍までのサイズのSearchable Snapshotのリストアを許容していることが分かります。
話を戻しましょう。totalRestoredRemoteIndexesSizeの計算でNullPointerExceptionが発生するということは、shardsIterator.getShardRoutings()によって得られたShardRoutingを引数とする、clusterInfo::getShardSizeが何らかの原因でnullを返しているものと推測できます。
long totalRestoredRemoteIndexesSize = shardsIterator.getShardRoutings()
.stream()
.map(clusterInfo::getShardSize)
.mapToLong(Long::longValue)
.sum();
ClusterInfoとRoutingTable
それでは、shardsIterator.getShardRoutings()によって得られたShardRoutingを引数とする、clusterInfo::getShardSizeがnullを返すのはどのような状況でしょうか。
ClusterInfo
まずはClusterInfoとは何であるかを考えます。
ClusterInfoとは、クラスタ全体のリソースに関する統計等の付加情報を管理するオブジェクトです。シャードのサイズやノードごとのディスク使用量などの情報を保持しています。
ClusterInfoの情報はManager Node(Master Node)が管理しており、InternalClusterInfoServiceが生成 / 更新を行います。
ClusterInfoの更新頻度はcluster.info.update.intervalで管理されており、デフォルトは30秒間です。
ShardRouting
それでは、ShardRoutingとはなんでしょうか。ShardRoutingとはRoutingTableを構成する要素の一部です。RoutingTableはどのシャードがどのノードに配置されているかや、各シャードの状態(STARTED, INITIALIZING, RELOCATINGなど)、プライマリ/レプリカの区別、インデックスごとのルーティング情報などクラスターの中核的な情報を管理します。
ShardRoutingは個々のシャード単位の詳細情報を保持します。
ShardRoutingはShardRoutingの構成要素として以下のような入れ子の関係にあります。
RoutingTable (クラスタ全体)
│
├─ Map<String, IndexRoutingTable> indicesRouting
│
└─ IndexRoutingTable (インデックスごと、ex: "my-index")
│
└─ Map<Integer, IndexShardRoutingTable> shards
│
└─ IndexShardRoutingTable (シャードIDごと、ex: shard 0)
│
└─ List<ShardRouting> (プライマリ + レプリカ)
│
├─ ShardRouting (primary=true) ← 個々のシャード
├─ ShardRouting (primary=false, replica 1)
└─ ShardRouting (primary=false, replica 2)
| コンポーネント | 粒度 | 役割 |
|---|---|---|
| RoutingTable | クラスタ全体 | 全インデックスのルーティング情報 |
| IndexRoutingTable | インデックス単位 | 1インデックスの全シャード |
| IndexShardRoutingTable | シャードID単位 | 1シャードの全レプリカ(primary+replica) |
| ShardRouting | シャード単位 | 1シャードの詳細 |
そして、RoutingTableは、cluster.info.update.interval周期で更新されるClusterInfoと異なり、クラスターの変更を即座に反映します。
つまり、RoutingTableが保持する情報がClusterInfoにない状況が発生し得るのです。
よって、以下のコードはClusterInfoが最新化されていない状態で、RoutingTableにのみ情報が存在するShard情報を処理する際に、.map(clusterInfo::getShardSize)がNullPointerExceptionを起こす可能性があると言えます。
long totalRestoredRemoteIndexesSize = shardsIterator.getShardRoutings()
.stream()
.map(clusterInfo::getShardSize)
.mapToLong(Long::longValue)
.sum();
仮説を元に再現検証する
ここまでの調査から、以下の仮説を立てました。
「1つ以上のリモートインデックスがリストア済で、かつその情報がClusterInfoに反映されていない状況で、新しいリモートインデックスをリストアした場合に、.map(clusterInfo::getShardSize)がNullPointerExceptionを起こすのではないか?」
この仮説を検証するためのコンテナ環境を用意しました。
ポイントは、searchable snapshotを使用するためnode.rolesにwarmを指定している点と、clusterInfoが古い状態を起こしやすくするため、cluster.info.update.intervalをデフォルトの30秒から60分に伸ばしていることです。
検証用のdocker-compose例
services:
opensearch-node1:
image: opensearchproject/opensearch:latest
container_name: opensearch-node1
environment:
- cluster.name=opensearch-cluster
- node.name=opensearch-node1
- discovery.seed_hosts=opensearch-node1,opensearch-node2
- cluster.initial_cluster_manager_nodes=opensearch-node1,opensearch-node2
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
- path.repo=/mnt/snapshots
- cluster.info.update.interval=60m
- cluster.info.update.timeout=10s
- node.roles=cluster_manager,data,ingest,warm
- node.search.cache.size=1gb
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-data1:/usr/share/opensearch/data
- ./snapshots:/mnt/snapshots
ports:
- 9200:9200
- 9600:9600
networks:
- opensearch-net
opensearch-node2:
image: opensearchproject/opensearch:latest
container_name: opensearch-node2
environment:
- cluster.name=opensearch-cluster
- node.name=opensearch-node2
- discovery.seed_hosts=opensearch-node1,opensearch-node2
- cluster.initial_cluster_manager_nodes=opensearch-node1,opensearch-node2
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
- path.repo=/mnt/snapshots
- cluster.info.update.interval=60m
- cluster.info.update.timeout=10s
- node.roles=cluster_manager,data,ingest,warm
- node.search.cache.size=1gb
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-data2:/usr/share/opensearch/data
- ./snapshots:/mnt/snapshots
networks:
- opensearch-net
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:latest # Make sure the version of opensearch-dashboards matches the version of opensearch installed on other nodes
container_name: opensearch-dashboards
ports:
- 5601:5601 # Map host port 5601 to container port 5601
expose:
- "5601" # Expose port 5601 for web access to OpenSearch Dashboards
environment:
OPENSEARCH_HOSTS: '["https://opensearch-node1:9200","https://opensearch-node2:9200"]' # Define the OpenSearch nodes that OpenSearch Dashboards will query
networks:
- opensearch-net
volumes:
opensearch-data1:
opensearch-data2:
networks:
opensearch-net:
そして、以下のステップで操作を行ったところ、事象を再現できました。
- Snapshot Repositoryを作成
PUT _snapshot/my_repository
{
"type": "fs",
"settings": {
"location": "/mnt/snapshots/my_repository"
}
}
- テスト用のインデックスを複数作成
PUT test-index-1
{"settings": {"number_of_shards": 2, "number_of_replicas": 0}}
... (repeat for test-index-2 through test-index-10)
- スナップショットを作成
PUT _snapshot/my_repository/snapshot_all?wait_for_completion=true
{
"indices": "test-index-*"
}
- test-index-1をリストア(
"storage_type": "remote_snapshot"を指定)
POST _snapshot/my_repository/snapshot_all/restore
{
"indices": "test-index-1",
"storage_type": "remote_snapshot",
"rename_pattern": "(.+)",
"rename_replacement": "remote1$1"
}
- 即座に別のスナップショットをリストア
POST _snapshot/my_repository/snapshot_all/restore
{
"indices": "test-index-2",
"storage_type": "remote_snapshot",
"rename_pattern": "(.+)",
"rename_replacement": "remote1$1"
}
# 事象再現!
# POST _snapshot/my_repository/snapshot_all/_restore
{
"error": {
"root_cause": [
{
"type": "null_pointer_exception",
"reason": null
}
],
"type": "null_pointer_exception",
"reason": null
},
"status": 500
}
修正の実施とPR
Issueへの報告
先ずはここまでの調査をIssueで報告し、修正のPull Requestを送るつもりがある旨を宣言しました。
開発環境の準備
実際の修正に取り掛かります。まずはOpenSearchのリポジトリをフォークして開発環境を準備します。
OpenSearch Projectは複数のリポジトリで構成されていますが、今回はopensearch-project
/OpenSearchが対象になります。
最初にContributing to OpenSearchとGetting Started as an OpenSearch Project Contributorに目を通しておくと良いでしょう。
GitHubでhttps://github.com/opensearch-project/OpenSearchにアクセスし、右上の「Fork」ボタンをクリックして自分のアカウントにリポジトリをコピーします。
フォークしたリポジトリをローカルにクローンします。
git clone https://github.com/YOUR_USERNAME/OpenSearch.git
cd OpenSearch
本家のリポジトリをupstreamとして登録します。
git remote add upstream https://github.com/opensearch-project/OpenSearch.git
git fetch upstream
修正用のブランチを作成します。
git checkout -b fix-19349-npe-restore-remote-snapshot
修正の実装
RestoreService.javaの該当箇所を修正します。clusterInfo.getShardSize()がnullを返す可能性を考慮したコードに変更します。
ここで、clusterInfo.getShardSize()の結果が得られない場合に、Snapshotのリストアそのものを失敗させるべきか、そのまま通過させるべきかについて、実装の方針を悩みました。
考えた結果、このvalidation処理は運用上の補助的なものである点を考慮して、WARNログを出してそのまま処理を続行する実装を提案して、レビュアーの意見を聞くことにしました。(結果的にこの方針が採用されました)
修正前:
long totalRestoredRemoteIndexesSize = shardsIterator.getShardRoutings()
.stream()
.map(clusterInfo::getShardSize)
.mapToLong(Long::longValue)
.sum();
修正後:
long totalRestoredRemoteIndicesSize = 0;
int missingSizeCount = 0;
List<ShardRouting> routings = shardsIterator.getShardRoutings();
for (ShardRouting shardRouting : routings) {
Long shardSize = clusterInfo.getShardSize(shardRouting);
if (shardSize != null) {
totalRestoredRemoteIndicesSize += shardSize;
} else {
missingSizeCount++;
}
}
if (missingSizeCount > 0) {
logger.warn(
"Size information unavailable for {} out of {} remote snapshot shards. "
+ "File cache validation will use available data only.",
missingSizeCount,
routings.size()
);
}
テストの追加
SearchableSnapshotIT.javaに統合テストを追加します。ClusterInfo更新を遅延させてshard sizeがnullになる状況を再現し、エラーが発生しないことを確認します。
ビルドとテスト
修正が完了したらローカルでビルドとテストを実行します。
./gradlew build
追加したテストだけを実行する場合:
./gradlew :server:internalClusterTest --tests "*.SearchableSnapshotIT.testRestoreRemoteSnapshotWithNullShardSizes"
E2Eでのテスト
テストコードは通過しましたが、念の為にローカルビルドを使ったテストも実施します。OpenSearchをビルドすると、 OpenSearch/distribution/docker配下にコンテナ環境用のビルドファイルが作成されるので、これを利用してテストを実施します。
docker load < opensearch-arm64_test_3.4.0-SNAPSHOT.docker.tar
検証用のdocker-compose例
services:
opensearch-node1:
image: opensearch:test
container_name: opensearch-local-node1
environment:
- cluster.name=opensearch-local-cluster
- node.name=opensearch-local-node1
- discovery.seed_hosts=opensearch-local-node1,opensearch-local-node2
- cluster.initial_cluster_manager_nodes=opensearch-local-node1,opensearch-local-node2
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
- path.repo=/mnt/snapshots
- cluster.info.update.interval=60m
- cluster.info.update.timeout=10s
- node.roles=cluster_manager,data,ingest,warm
- node.search.cache.size=1gb
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-local-data1:/usr/share/opensearch/data
- ./snapshots:/mnt/snapshots
ports:
- 9200:9200
- 9600:9600
networks:
- opensearch-local-net
opensearch-node2:
image: opensearch:test
container_name: opensearch-local-node2
environment:
- cluster.name=opensearch-local-cluster
- node.name=opensearch-local-node2
- discovery.seed_hosts=opensearch-local-node1,opensearch-local-node2
- cluster.initial_cluster_manager_nodes=opensearch-local-node1,opensearch-local-node2
- bootstrap.memory_lock=true
- "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m"
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
- path.repo=/mnt/snapshots
- cluster.info.update.interval=60m
- cluster.info.update.timeout=10s
- node.roles=cluster_manager,data,ingest,warm
- node.search.cache.size=1gb
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- opensearch-local-data2:/usr/share/opensearch/data
- ./snapshots:/mnt/snapshots
networks:
- opensearch-local-net
opensearch-dashboards:
image: opensearchproject/opensearch-dashboards:latest
container_name: opensearch-local-dashboards
ports:
- 5601:5601
expose:
- "5601"
environment:
OPENSEARCH_HOSTS: '["http://opensearch-local-node1:9200","http://opensearch-local-node2:9200"]'
DISABLE_SECURITY_DASHBOARDS_PLUGIN: "true"
networks:
- opensearch-local-net
volumes:
opensearch-local-data1:
opensearch-local-data2:
networks:
本環境で改めて再現検証を行ったところ、NullPointerExceptionが発生せず、想定通りのWARNログが出ていることが確認できました。
{"type": "server", "timestamp": "2025-11-09T09:06:44,242Z", "level": "WARN", "component": "o.o.s.RestoreService", "cluster.name": "opensearch-local-cluster", "node.name": "opensearch-local-node2", "message": "Size information unavailable for 8 out of 8 remote snapshot shards. File cache validation will use available data only.", "cluster.uuid": "dbwGOLHwSQKDy6fM624y0A", "node.id": "l6v98Z5KQXG2Qm8BwAxJPA" }
コミット
テストが通ったら変更をコミットします。OpenSearchではDCO (Developer Certificate of Origin) への準拠が必要です。
まずGitの設定を確認します。
git config --global user.name "Your Name"
git config --global user.email "your.email@example.com"
修正したファイルをコミットします。-sオプションで自動的にSigned-off-by行が追加されます。
git add server/src/main/java/org/opensearch/snapshots/RestoreService.java
git add server/src/internalClusterTest/java/org/opensearch/snapshots/SearchableSnapshotIT.java
git add CHANGELOG.md
git commit -s -m "Fix NPE in RestoreService when shard size is unavailable"
Pull Requestの作成
変更をGitHubにプッシュします。
git push origin fix-19349-npe-restore-remote-snapshot
GitHubの自分のフォークページにアクセスすると「Compare & pull request」ボタンが表示されるのでクリックしてPRを作成します。
CHANGELOG.mdの追記
OpenSearch Projectのポリシーに従って、CHANGELOG.mdを更新しましょう。
PR作成後、マージまで
PR作成後、1日程度でレビューして貰えました。これは一般的なOSSとしては、かなり早い方である感覚です。これまでにOpenSearchに送ってきた他のPRも全て1-2日程度で最初のレスポンスを貰えており、コントリビューションにオープンな姿勢が感じられてとても良いです。
レビューでは、軽微な変数名の修正のみが指摘事項として挙がり、数日でApprove Changesを貰うことができました。
PR時に少し困った点としては、CIが全体的に不安定で、Flaky Testが原因で無関係なチェックで引っ掛かる事象が多発しました。ただ、解決に向けてメンテナの方々にサポートして頂けたので、それほど深刻な課題にはなりませんでした。
これに関しては、Flaky Testを検出するBotが組み込まれているなど、改善への取り組みも進行中のようです。関心がある方は、CIの安定化へのコントリビューションを検討しても良いかもしれません。
まとめ
本記事では、OpenSearchの検証を通じて発見したバグの原因を調査し、修正のPRを作成してマージしてもらうまでの一連の流れを実体験に基づいてご紹介しました。
OpenSearchはコミュニティによって活発に開発が行われているソフトウェアであり、新しい機能の追加や、既存の挙動の改善まで、様々な形でコントリビューションできる余地があります。本記事でOpenSearchへの貢献に興味を持ち、挑戦される方が現れるのを心待ちにしております!
もし、OpenSearchへの貢献に興味が湧いたら、以下の記事もぜひ参考にしてみてください。
OpenSearch Project(OSS) の Publicationです。 OpenSearch Tokyo User Group : meetup.com/opensearch-project-tokyo/
Discussion