Everything for OpenSearch in one place — pick a section below. 13 reviewed items across 4 content types, plus a hands-on tutorial.
Quick Answer
An OpenSearch index is split into a fixed number of primary shards at creation time, each an independent Lucene index holding a portion of the data, determined by hashing each document's routing value against that exact shard count. Every primary can have one or more replica shards, which are exact copies used for redundancy and read scaling. The primary count is locked in at creation because the routing formula depends on that exact number — changing it later would misroute every existing document, so 'resizing' primary count means reindexing into a brand-new index instead.
Detailed Answer
Imagine a large apartment complex's management company decides upfront to divide all tenant mail into exactly ten mailroom bins, using a formula like the last digit of the apartment number. Every mail carrier and every tenant relies on that formula to know which bin holds which mail. If the company suddenly switched to seven bins instead of ten, the old formula's results become meaningless — mail correctly sorted into bin six under the ten-bin formula might belong somewhere completely different under the seven-bin formula, so every single piece of mail in the building would have to be physically re-sorted to make the new bin count correct.
OpenSearch indices work the same way. When you create an index, you specify number_of_shards, the primary shard count, defaulting to one in modern versions but historically five, and every document written to that index is deterministically routed to one specific primary shard using a hash of its _id, or a custom routing value, modulo the shard count. This fixed, pre-committed shard count exists because that routing formula must stay constant for OpenSearch to reliably find a document later — if the shard count could silently change, the same formula applied to the same document would compute a different shard, breaking lookups for anything indexed before the change took effect.
Internally, each primary shard is a fully independent Lucene index, Lucene being the underlying single-node search library OpenSearch is built on, with its own inverted index, segments, and merge policy. Replica shards are exact copies of a primary, kept in sync as new documents are written — the primary indexes first, then replicates the operation to its replicas — and OpenSearch can route read and search requests to either a primary or any of its replicas to spread query load, while writes always go through the primary first. The cluster manager tracks which node holds which shard copy and handles reassignment if a node holding a shard goes down unexpectedly.
In production, choosing the primary shard count upfront matters enormously: too few shards for a large, growing dataset, like a year of payments-api-logs, means each shard grows huge and becomes slow to search and rebalance; too many small shards across a cluster wastes memory on per-shard overhead, since each shard consumes file handles and heap for its own Lucene structures, and can itself become a cluster stability problem at scale. Teams commonly use rollover-based index patterns, creating a new index every day or week and aliasing them together, specifically to sidestep the 'I picked the wrong shard count forever' problem, since they're setting the shard count for one time-bounded chunk of data rather than for all data forever.
The gotcha: engineers coming from relational databases often assume you can just add more shards the way you'd add more database partitions dynamically, but OpenSearch has no live operation for primary shards at all — the only path is the _reindex, _split, or _shrink APIs, which physically copy or reorganize data into a brand-new index with a different shard count. That means correcting a sizing mistake after the fact requires enough temporary disk and cluster capacity to hold both the old and new index simultaneously during the migration, which is often the real operational blocker rather than the reindex logic itself being complicated.
Code Example
# shard count is fixed at creation - choose deliberately
curl -s -X PUT localhost:9200/payments-api-logs-2026.07 -H 'Content-Type: application/json' -d '{
"settings": {
"number_of_shards": 3, # locked in forever for this index
"number_of_replicas": 1 # this one CAN be changed anytime
}
}'
# the only way to change primary shard count after the fact: reindex into a new index
curl -s -X POST localhost:9200/_reindex -H 'Content-Type: application/json' -d '{
"source": { "index": "payments-api-logs-2026.07" },
"dest": { "index": "payments-api-logs-2026.07-v2" }
}'
# payments-api-logs-2026.07-v2 was created beforehand with the corrected number_of_shardsInterview Tip
A junior engineer typically says 'OpenSearch splits data into shards for scaling,' without explaining why the count is locked in. For a senior/architect role, the interviewer is actually looking for you to explain the routing formula dependency that makes shard count immutable, and to describe practical mitigation strategies teams actually use, like rollover indices with time-based aliases, or the split/shrink/reindex APIs, plus the real operational cost of those migrations — double the disk and double the cluster capacity, temporarily. This shows you understand a foundational OpenSearch constraint that regularly surprises people coming from relational database backgrounds.
◈ Architecture Diagram
┌───────────────────────────┐ │ Index (3 primary shards) │ │ ┌────┐ ┌────┐ ┌────┐ │ │ │ P0 │ │ P1 │ │ P2 │ │ │ └────┘ └────┘ └────┘ │ │ │ │ │ replicas can │ ↓ ↓ ↓ change anytime │ ┌────┐ ┌────┐ ┌────┐ │ │ │ R0 │ │ R1 │ │ R2 │ │ │ └────┘ └────┘ └────┘ │ └───────────────────────────┘
💬 Comments
Quick Answer
Check per-node and per-shard search latency via _nodes/stats and _cat/shards — if latency concentrates on one or two nodes while others sit idle, it's a hot shard or hot-spotting problem. If latency is uniformly elevated across all nodes and correlates with JVM garbage collection pauses visible in _nodes/stats/jvm, it's GC pressure. If latency correlates with specific query shapes rather than specific nodes, use the _search profile API to find an expensive query pattern, like an unbounded wildcard or deep pagination, that's the actual culprit. Business-hours-only timing itself is a clue: it points toward load-correlated causes rather than a one-time bad deploy.
Detailed Answer
Imagine three different reasons one specific highway toll booth causes traffic jams only during rush hour. Maybe that one booth happens to be the only one open on a stretch everyone needs, a hot booth getting disproportionate traffic. Maybe all booths periodically pause for a few seconds to do maintenance, and it's simply more noticeable when traffic is already heavy, a system-wide periodic pause like garbage collection. Or maybe certain cars, oversized trucks needing manual inspection, take much longer at any booth, and rush hour just has more of them passing through, expensive individual transactions. All three look identical from a distance, 'slow at rush hour,' but the fix for each is completely different.
OpenSearch surfaces node- and shard-level metrics precisely because these three failure modes, hot-spotting, JVM GC pressure, and expensive query patterns, look identical from a black-box 'average latency went up' dashboard but require completely different fixes. This is a direct consequence of it being a distributed system built on the JVM: work is spread across shards and nodes by design, not perfectly by usage pattern; memory management is handled by JVM garbage collection, which can pause application threads; and it accepts arbitrary user-composed queries whose cost varies enormously depending on shape.
To isolate the cause, GET _cat/shards?v combined with GET _nodes/stats/indices/search shows per-node search thread pool queue size and latency — if two nodes show dramatically higher search latency and larger queues while others sit near-idle, those nodes likely host a disproportionately hot shard, common when one heavily-queried index, like a current day's checkout-worker index in a daily-rollover pattern, isn't evenly distributed or a single shard receives most of the traffic. GET _nodes/stats/jvm shows GC pause counts and duration per node — if pause times spike in lockstep across most nodes correlating with the latency spike, it's memory pressure, often from oversized aggregations or too many open shards consuming heap. Finally, the _search profile API run against actual slow queries shows exactly where time is spent inside a single query's execution, revealing patterns like leading-wildcard queries that can't use the index efficiently, or deep from/size pagination that forces scanning and sorting far more documents than are actually returned to the client.
In production, the business-hours-only pattern is itself diagnostic: if latency is purely proportional to query volume, both hot-shard and GC-pressure theories remain plausible, since more load worsens both. But if latency is disproportionately worse than the increase in query volume would suggest, that points toward a resource ceiling being hit, like heap approaching its limit and triggering more frequent or longer GC pauses, rather than simple linear load. Teams typically build dashboards correlating query rate, GC pause time, and per-node search latency on the same timeline, specifically to make this kind of correlation-based diagnosis fast during an actual incident instead of guessing under pressure.
The non-obvious gotcha: a hot shard problem can be entirely self-inflicted by well-intentioned index design — using a low-cardinality routing key, like routing all documents for one large customer to the same shard for locality, creates a whale shard that gets far more traffic than its siblings. This only becomes visible under real business-hours load, never during off-peak testing, meaning a routing strategy that looked perfectly fine in staging can create a production-only hot-spotting incident that's genuinely hard to reproduce outside peak traffic windows.
Code Example
# per-node/shard search latency - look for concentration on one or two nodes
curl -s 'localhost:9200/_cat/shards?v&h=index,shard,prirep,node,docs,store' | sort -k4
# GC pause frequency/duration per node - look for correlation with the latency spike
curl -s localhost:9200/_nodes/stats/jvm?pretty | jq '.nodes[].jvm.gc.collectors'
# profile an actual slow query to find the expensive component
curl -s -X GET localhost:9200/checkout-worker-logs-*/_search?pretty -H 'Content-Type: application/json' -d '{
"profile": true,
"query": { "wildcard": { "message": "*timeout*" } }
}'Interview Tip
A junior engineer typically jumps straight to 'scale up the cluster,' throwing hardware at an undiagnosed problem. For a senior/architect role, the interviewer is actually looking for a structured, metric-driven differential diagnosis: per-node and per-shard latency for hot-spotting, JVM GC stats for memory pressure, and the query profile API for expensive query shapes, ideally combined with the insight that business-hours-only timing itself narrows the hypothesis space toward load-correlated causes. Recognizing that a custom routing key can create a self-inflicted hot shard invisible until real peak traffic hits is a strong senior-level signal that goes beyond textbook troubleshooting steps.
◈ Architecture Diagram
┌──────┐ ┌──────┐ ┌──────┐ │Node A│ │Node B│ │Node C│ │ 95ms │ │ 12ms │ │ 11ms │ ← hot shard on A └──────┘ └──────┘ └──────┘ vs GC pause ↑ on ALL nodes = memory pressure slow only on wildcard query = query shape
💬 Comments
Quick Answer
Watch JVM heap usage sampled immediately after garbage collection, specifically the old-generation pool, trending upward over time, not the raw instantaneous heap value which fluctuates constantly regardless of real pressure. Also watch GC pause frequency and duration increasing, and circuit breaker trip counts via _nodes/stats/breaker, which fire as a deliberate early safety mechanism before an actual OutOfMemory crash. A steadily rising post-GC heap floor is the single most reliable predictor — it means the JVM can't reclaim enough memory even right after collection, so a crash is a matter of time, not chance.
Detailed Answer
Think of a shared office kitchen with a small trash can emptied every hour on the hour. If the can is already 90 percent full right after being emptied, you know it will overflow again well before the next hour is up — the fact that it's nearly full immediately post-cleanup is what tells you there's a real capacity problem, not just a matter of timing when you happened to check. If instead the can was only 10 percent full right after emptying, you'd know there's plenty of headroom even if it fills up 90 percent again just before the next scheduled cleaning.
OpenSearch, running on the JVM, exhibits exactly this pattern with heap memory: raw heap usage sampled at any random moment is nearly meaningless, since it depends heavily on whether a garbage collection cycle just ran. But heap usage sampled immediately after a garbage collection tells you how much memory is genuinely still in use by things that can't be freed, and a rising trend there means real memory pressure, not normal churn. OpenSearch's circuit breakers exist specifically because engineers learned that letting the JVM run out of heap causes an ungraceful OutOfMemoryError that can corrupt in-progress operations, so circuit breakers proactively reject a request that would push memory over a configured threshold, trading 'this one query fails cleanly' for 'the whole node doesn't crash.'
Internally, several circuit breakers are tracked separately: the parent breaker, an overall memory budget across all breakers combined; the field data breaker, memory used loading field values into memory for sorting or aggregating on fields not backed by doc_values; the request breaker, a per-request memory estimate for something like a large aggregation; and the in-flight requests breaker, memory held by requests currently being processed. Each increments a trip counter in _nodes/stats/breaker when it rejects an operation. Meanwhile _nodes/stats/jvm/mem/pools/old shows the old-generation heap pool specifically, where long-lived objects like shard-level caches accumulate — a steady upward trend in that value, sampled right after each GC cycle over hours or days, is the clearest predictive signal of an eventual crash.
In production, dashboards graph old-gen heap-after-GC as a time series, never raw instantaneous heap, alongside GC pause duration and frequency, since a rising trend in time spent in GC per minute means the JVM is working harder and harder to reclaim less and less memory, and circuit breaker trip counts per breaker type, since a rising field-data breaker trip count specifically often points to aggregations or sorts running on text fields that should have doc_values-backed keyword fields instead. Alerting typically pages on sustained old-gen usage above roughly 75 to 85 percent of max heap measured post-GC, since that's the zone where a single burst of memory-heavy queries can tip a node into a full OOM crash.
The non-obvious gotcha: circuit breakers tripping frequently isn't just an inconvenience to tune away — teams sometimes respond to breaker trips by raising the breaker's limit purely to stop the errors, which removes the exact safety margin that was preventing an actual OOM crash. The trips are a symptom, something is requesting too much memory per operation, often a poorly-designed aggregation or a script-based sort, and the correct fix is almost always addressing the query pattern or adding heap and nodes, not loosening the breaker that's actively protecting the cluster.
Code Example
# old-generation heap AFTER GC - the meaningful trend, not raw instantaneous heap curl -s localhost:9200/_nodes/stats/jvm?pretty | jq '.nodes[].jvm.mem.pools.old' # circuit breaker trip counts by type - rising trips = something requesting too much memory curl -s localhost:9200/_nodes/stats/breaker?pretty | jq '.nodes[].breakers' # GC pause frequency and total duration per node curl -s localhost:9200/_nodes/stats/jvm?pretty | jq '.nodes[].jvm.gc.collectors.old'
Interview Tip
A junior engineer typically says 'watch heap usage and alert if it's high,' which is nearly useless because raw heap fluctuates constantly with normal GC cycles regardless of real pressure. For a senior/architect role, the interviewer is actually looking for you to specifically name post-GC old-generation heap trend as the meaningful signal, explain what circuit breakers are actually protecting against, and flag the common but dangerous anti-pattern of raising breaker limits to silence trip errors instead of fixing the underlying expensive query or aggregation pattern that's requesting too much memory per operation in the first place.
◈ Architecture Diagram
┌───────────────────────┐ │ old-gen heap after GC │ │ day 1: ● 40% │ │ day 2: ● 55% │ │ day 3: ● 70% ↑ │ trending up = predicts OOM └───────────────────────┘ circuit breaker trips ↑ = symptom, not the disease
💬 Comments
Quick Answer
Define an ISM policy with time- or size-based rollover and age-based deletion, but add an explicit hold mechanism — a policy exception or allowlist for indices tagged during an active incident, or a minimum retention floor long enough that automated deletion never outruns your realistic investigation window — combined with alerting before any delete action actually executes. The key design principle is that automated deletion should never be instantaneous or silent; there needs to be a visible warning period and an explicit override path for indices someone has flagged as still needed.
Detailed Answer
Think about an office building's shredding service that comes weekly to destroy documents past a set retention period. A well-run office doesn't let the shredding truck take anything past the cutoff date automatically and irreversibly — there's a process where anyone who knows a specific box of documents is part of an active legal matter can tag it 'hold, do not shred,' and the shredding service respects that tag even if the box is technically past its normal retention date on paper.
OpenSearch's Index State Management plugin automates exactly this kind of lifecycle for time-series log indices, moving indices through states like hot, actively written; warm, read-only and less resourced; cold; and eventually delete, based on age or size thresholds. It exists because manually rolling over and deleting daily log indices across dozens of services doesn't scale, and disk exhaustion from unbounded log retention is one of the most common self-inflicted OpenSearch outages. But a fully automated deletion pipeline is dangerous without a safety valve, since incident investigations frequently need log data older than the 'normal' retention window, especially for a slow-building issue that took days or weeks to even notice.
A safe ISM setup defines a policy per index pattern, like payments-api-logs-*, with rollover conditions based on index age or size, for example rolling over daily or at 50GB, a warm-phase transition after some days to move less-queried indices to cheaper, denser nodes, and a delete phase after a defined retention, say 30 or 90 days depending on compliance needs. The safety mechanism layered on top: before the delete action actually executes, ISM can be configured to check a metadata flag or a separate protected-indices allowlist, maintained via a small automation script responding to an incident-hold tag applied by on-call engineers, and skip deletion for any index matching that flag, plus send a notification through the policy's notification action a set number of days before deletion actually happens, giving humans a real window to intervene before data is gone.
In production, teams monitor ISM policy execution history — GET _plugins/_ism/explain on an index shows its current state and any errors in transitioning — to catch policies stuck retrying a failed transition, often due to an allocation problem preventing a warm-phase relocation, and track disk usage trends to confirm the retention policy is actually keeping pace with data growth. A policy that's technically running but consistently behind due to failed transitions provides a false sense of safety while disk quietly fills anyway, undetected until it becomes a real incident of its own.
The non-obvious gotcha: teams often set retention purely based on typical operational needs, like 'we only ever look back 14 days,' without accounting for the fact that security or compliance incidents are frequently discovered weeks or months after the fact. By the time someone realizes a suspicious authentication pattern from user-auth-service six weeks ago needs investigating, a 30-day ISM delete policy has already permanently destroyed the evidence. Retention windows need to be set based on the slowest realistic detection time for the kinds of incidents that matter most, not just typical day-to-day debugging needs that rarely look back more than a couple of weeks.
Code Example
# ISM policy: rollover, warm phase, delete phase with a hold check
curl -s -X PUT localhost:9200/_plugins/_ism/policies/payments-api-logs-policy -H 'Content-Type: application/json' -d '{
"policy": {
"description": "rollover daily, warm after 7d, delete after 90d unless held",
"default_state": "hot",
"states": [
{ "name": "hot", "actions": [{ "rollover": { "min_index_age": "1d" } }], "transitions": [{ "state_name": "warm", "conditions": { "min_index_age": "7d" } }] },
{ "name": "warm", "actions": [{ "replica_count": { "number_of_replicas": 0 } }], "transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "90d" } }] },
{ "name": "delete", "actions": [{ "notification": { "channel": { "slack": {"url": "https://hooks.slack/oncall"} }, "message_template": {"source": "deleting {{ctx.index}} in 3 days unless held"} }}, { "delete": {} }] }
]
}
}'
# tag an index as held during an active incident, checked before delete executes
curl -s -X PUT localhost:9200/payments-api-logs-2026.06.01/_settings -H 'Content-Type: application/json' -d '{ "index.ism.hold": true }'Interview Tip
A junior engineer typically says 'set up ISM to delete old indices automatically,' treating it as a pure disk-management problem with no downside. For a senior/architect role, the interviewer is actually looking for you to recognize the tension between automated retention, necessary for disk sanity, and incident or compliance needs, which can require data older than 'normal' retention. They want a concrete safety mechanism described: a hold or allowlist mechanism, pre-deletion notifications, and retention windows sized to realistic detection latency rather than typical day-to-day usage patterns, showing real operational maturity about irreversible automated actions.
◈ Architecture Diagram
┌─────┐ ┌──────┐ ┌──────┐ ┌────────┐
│ hot │ → │ warm │ → │notify│ → │ delete │
└─────┘ └──────┘ └──────┘ └────────┘
│ │
ism.hold=true → skip delete💬 Comments
Quick Answer
Yellow means every primary shard is assigned but at least one replica isn't, so data is safe but under-replicated; red means at least one primary shard itself is unassigned, meaning some data is genuinely unavailable for reads and writes on that index. Use GET _cluster/allocation/explain to see OpenSearch's own reasoning for why a specific shard won't allocate — usually disk watermark limits, an incompatible allocation filter, or not enough distinct nodes to satisfy the replica count — and fix the underlying constraint rather than forcing allocation blindly.
Detailed Answer
Imagine a library with a rule: every book, the primary, needs at least one photocopy stored in a different building, the replica, in case a fire destroys the original building. Yellow status is like discovering some books don't have their backup copy made yet — mildly concerning, but the original book is still on the shelf and readable right now. Red status is like discovering the original book itself is missing from every building — nobody can check it out at all, because there's no copy anywhere to fall back on.
OpenSearch was designed around this primary/replica shard model specifically so data survives individual node failures, and cluster health is a deliberately simple three-color signal, green, yellow, red, layered on top of much more granular shard-level state, so operators get an at-a-glance severity assessment without parsing every shard's status during a fast-moving incident. Yellow is explicitly designed to be safe-but-degraded, you still have all your data, just less redundancy, while red is designed to be unambiguous: real, present data unavailability that needs immediate attention.
Under the hood, the cluster manager node, the elected coordinator formerly called master, continuously tries to allocate any unassigned shard to an eligible data node, subject to allocation rules: disk-based shard allocation watermarks, where a low watermark stops new shard allocation onto a node, a high watermark starts relocating shards away, and a flood-stage watermark makes indices read-only to prevent a full disk; shard allocation awareness and filtering, for example keeping replicas in different availability zones; and simply whether enough distinct nodes exist to hold a primary and all its replicas without doubling up on the same node. GET _cluster/allocation/explain surfaces exactly which of these rules is currently blocking a specific shard from allocating, turning 'why is my cluster yellow' from a guessing game into a direct, actionable answer.
In production, teams alert on cluster status transitioning to yellow at a ticket level, especially if it lingers beyond a normal maintenance window, and page immediately on red, while separately monitoring disk usage per node against the configured watermarks. The single most common cause of unassigned shards in real fleets is nodes crossing the low or high disk watermark during heavy indexing from something like a payments-api-logs index, which blocks new shard placement and can cascade into more shards becoming unassigned as remaining nodes also fill up in turn.
The gotcha: naively raising or disabling the disk watermarks to make yellow status go away, a common quick workaround under pressure, doesn't fix anything — it just lets OpenSearch keep placing shards onto nodes that are genuinely running low on disk, setting up a much worse red-status, out-of-disk-space incident later. The correct fix is almost always adding capacity, deleting or rolling over old indices via an ILM or ISM policy, or rebalancing shard distribution across existing nodes, not moving the watermark goalposts to hide the underlying pressure.
Code Example
# check overall cluster health first
curl -s localhost:9200/_cluster/health?pretty
# ask OpenSearch WHY a specific shard won't allocate
curl -s -X GET localhost:9200/_cluster/allocation/explain?pretty -H 'Content-Type: application/json' -d '{
"index": "payments-api-logs-2026.07.05",
"shard": 0,
"primary": false
}'
# check disk watermark thresholds currently configured
curl -s localhost:9200/_cluster/settings?include_defaults=true&filter_path=**.disk.watermark*
# only after fixing root cause: nudge a stuck shard to reroute
curl -s -X POST localhost:9200/_cluster/reroute?retry_failed=trueInterview Tip
A junior engineer typically recites 'yellow is replicas, red is primaries,' which is correct but shallow. For a senior/architect role, the interviewer is actually looking for you to walk through the real allocation decision process — disk watermarks, allocation awareness and filtering, node count constraints — using _cluster/allocation/explain as your diagnostic tool rather than guessing. They also want you to flag the dangerous anti-pattern of loosening watermarks just to make yellow or red disappear cosmetically, which defers a disk-exhaustion incident rather than solving it, showing you understand the difference between making a symptom disappear and fixing the root cause.
◈ Architecture Diagram
┌────────┐ green: all primary+replica ✓
│Cluster │ yellow: primary ✓ replica ✗
│ Health │ red: primary ✗ (data gone)
└────────┘
│
↓
_cluster/allocation/explain → reason (watermark, filter, node count)💬 Comments
Context
A company stuck on Elasticsearch 7.10 (the last Apache-2.0 release) for licensing reasons: no security patches, plugins aging out, and a compliance requirement for authenticated access that the OSS distribution never included.
Problem
Upgrading forward meant Elastic's license; staying meant unpatched CVEs and no RBAC. The 15TB cluster served live dashboards and alerting for 60 teams — a big-bang migration was untenable.
Solution
Migrated to OpenSearch with a phased dual-write: stood up the OpenSearch cluster (same topology, security plugin configured with OIDC + per-team roles from day one), pointed ingestion (Logstash/Beats via the compatibility setting) at both clusters, backfilled 30 days of hot data via snapshot-and-restore (7.10 snapshots restore cleanly into OpenSearch), and moved consumers in waves — dashboards first (OpenSearch Dashboards import from Kibana saved objects, fixing the handful of incompatible visualizations), then alerting rebuilt on the OpenSearch alerting plugin. Two weeks of dual-run with query-result spot comparisons, then ingestion cut to OpenSearch-only and the old cluster read-only until retention aged it out.
Commands
snapshot: PUT _snapshot/migrate/s1 (on ES 7.10) -> POST _snapshot/migrate/s1/_restore (on OpenSearch)
filebeat: output.elasticsearch: {hosts: [both], allow_older_versions handling via compat mode}security: config OIDC + roles_mapping.yml per team
Outcome
Full cutover in six weeks with zero ingestion gaps; RBAC and audit logging satisfied the compliance finding that started it all; patch cadence resumed. Two Kibana visualizations needed rebuilds (TSVB edge cases); everything else imported.
Lessons Learned
The 7.10 snapshot-compatibility path made backfill trivial — orgs migrating from newer ES versions face a harder reindex-based road, so the timing (before drifting further) mattered. Rebuilding alerting was the real work; dashboards were the easy 80%.
💬 Comments
Context
A fintech required to keep transaction-adjacent logs queryable for seven years — previously 'solved' by a giant cluster where 95% of storage held data queried a few times a year during audits.
Problem
Hot-tier disk economics made seven-year retention absurd; the alternative (cold S3 dumps without query capability) failed the 'queryable' requirement — auditors expect answers in hours, not a restore project.
Solution
Built a tiered lifecycle with ISM: hot (7 days, NVMe data nodes) -> warm (90 days, dense storage, force-merged read-only, reduced replicas) -> cold/searchable-snapshot tier backed by S3 for years 1-7, with indices detached from local storage but re-attachable for query on demand. ISM policies automate every transition including notification on failure; a quarterly audit-drill queries a random historical month end-to-end to prove the path works and measures time-to-answer. Index naming and templates encode data class so ISM policies attach automatically at creation.
Commands
PUT _plugins/_ism/policies/txn-logs {states: [hot -> warm(90d): {force_merge, replica_count: 0, read_only} -> snapshot+delete_local(1y)]}ISM template: {index_patterns: ['txn-*'], policy_id: 'txn-logs'}drill: restore month-index from snapshot repo -> run audit query set -> record duration
Outcome
Storage cost for the archive dropped ~85% versus all-hot; the seven-year requirement is met with audit queries answering in under two hours (restore + query); ISM failure notifications caught two stuck transitions before they became retention violations.
Lessons Learned
The quarterly drill is what makes the archive real — the first drill found a snapshot repository permission rot that would have failed an actual audit. Encode data class in index naming from day one; retrofitting templates onto mixed indices was the painful part.
💬 Comments
Symptom
One or more indices become read-only without warning, write requests to those indices start failing, and cluster health shows red/yellow, all shortly after a data node's disk usage crossed a threshold — even though the node itself is still up and reachable.
Error Message
blocked by: [FORBIDDEN/12/index read-only / allow delete (api)]
Root Cause
OpenSearch/Elasticsearch's disk-based shard allocation has three thresholds: at the low watermark (~85%) it stops allocating new/replica shards to the node; at the high watermark (~90%) it actively relocates shards away from the node; at flood-stage (~95%) it forces every index with a shard on that node into read-only mode as a self-preservation measure, regardless of whether the write itself would have fit. If disk usage crosses flood-stage before capacity or cleanup catches up, writes fail immediately and cluster health degrades even though no data has been lost.
Diagnosis Steps
Solution
Free up disk space on the affected node(s) (delete/close old indices, adjust ILM/retention, or add capacity), then once usage drops back below the flood-stage threshold, explicitly clear the read-only-allow-delete block on affected indices — OpenSearch does not automatically un-set it just because disk usage recovered.
Commands
GET _cluster/allocation/explain
GET _cat/allocation?v
PUT <index>/_settings {"index.blocks.read_only_allow_delete": null}Prevention
Alert well before the low watermark (e.g. at 75-80%) rather than waiting for flood-stage to be the first signal, and tie index lifecycle management (retention/rollover) to actual disk headroom trends rather than a fixed schedule that may not keep pace with ingest growth.
💬 Comments
Symptom
During a network partition (or severe latency event) between nodes, the cluster appears to split into two functioning-but-disconnected groups, each reporting itself healthy with its own elected master, and each accepting writes independently — resulting in two versions of the same index diverging until the partition heals.
Error Message
none single error — surfaces as duplicate/conflicting master node reports and divergent document versions across the two groups after reconciliation
Root Cause
If a cluster's master-eligible node count and quorum configuration don't enforce a proper majority requirement for master election (historically a risk with too few master-eligible nodes, or misconfigured quorum settings), a network partition can leave both sides of the split believing they have enough nodes to elect a master, producing two independently-writable clusters — the classic split-brain scenario — rather than one side correctly recognizing it lacks quorum and refusing to elect.
Diagnosis Steps
Solution
Once connectivity is restored, do not simply let the two sides silently merge — assess which side has the authoritative data (commonly based on which retained the majority/quorum during the partition) and be prepared to manually reconcile or discard divergent writes from the minority side that shouldn't have been accepted in the first place.
Commands
GET _cat/nodes?v&h=name,master,node.role
GET _cluster/state/master_node
GET _cluster/health?level=indices
Prevention
Run at least 3 dedicated master-eligible nodes (never 2, which cannot form a proper majority) so quorum-based election correctly refuses to elect a master on the minority side of any partition. Treat this as a non-negotiable production topology requirement, not a scaling nice-to-have.
💬 Comments
Symptom
A routine access review (quarterly) finds that members of the analytics team can query a customer-support index containing PII — and query logs show they actually have, via a cross-index dashboard someone built weeks earlier. No alert ever fired; nothing was 'broken'.
Error Message
No error. The audit log, once inspected, shows successful searches by analytics-role principals against support-* indices beginning the day after a security-plugin config deployment.
Root Cause
A roles.yml refactor (consolidating duplicated role definitions during an upgrade) changed an index pattern from support-internal-* to support-* on a broadly-granted read role — a one-character-class diff that widened the grant to include support-pii-* indices. Config was deployed via securityadmin.sh without review of the effective-permission diff, and no automated check compared before/after access matrices. The over-grant was discovered by calendar, not by control.
Diagnosis Steps
Solution
Tightened the pattern immediately and invalidated cached role mappings; ran the audit-log history to scope actual exposure (bounded: one dashboard, internal users, logged queries) for the privacy team's assessment. Structurally: security config moved to Git with PR review, a CI job that diffs effective permissions (resolves patterns against the live index list and compares role-by-role), and a canary check asserting the PII index list is reachable only by its named roles — run daily, paging security on violation.
Commands
securityadmin.sh -r # retrieve current config for diffing
GET _plugins/_security/api/roles/analytics_read | jq '.analytics_read.index_permissions'
audit log query: principal=analytics-* AND index=support-pii-* AND action=search
Prevention
Access-control config is code: review diffs of effective permissions, not YAML text. Index patterns in grants are the sharp edge — prefer explicit prefixes with tests over broad globs. Continuously verify crown-jewel indices' accessibility against an expected-roles allowlist rather than trusting quarterly reviews to catch drift.
💬 Comments