Elasticsearch Internals
Distributed search and analytics engine built on Apache Lucene. Horizontally scalable, schema-flexible, and optimized for full-text search and near-real-time analytics.
1. What Elasticsearch Is
- Search engine: inverted index for full-text search with relevance scoring
- Analytics engine: aggregations over large datasets (like SQL GROUP BY but distributed)
- Document store: JSON documents, schemaless or schema-controlled via mappings
- Built on Lucene: each shard is a Lucene index; ES adds distribution, replication, and a REST API
Not a primary database — no ACID transactions, no joins across indexes. Use it for search, log analytics (ELK stack), and metrics aggregation.
2. Core Architecture
| Concept | Description |
|---|---|
| Cluster | One or more nodes sharing the same cluster.name |
| Node | Single running ES instance |
| Index | Logical namespace for documents (like a database table) |
| Shard | Physical unit — a single Lucene index. Index is split into N primary shards |
| Replica | Copy of a primary shard for HA and read scaling |
Node Types
| Role | Responsibility |
|---|---|
| Master | Manages cluster state: node join/leave, index create/delete, shard allocation |
| Data | Stores shards, executes queries and indexing |
| Ingest | Pre-processes documents (pipelines) before indexing |
| Coordinating | Routes requests, merges results — every node is coordinating by default |
| ML | Runs machine learning jobs (X-Pack) |
A node can have multiple roles. In production, dedicate master and data nodes.
3. Cluster Topology Diagram
graph TD
Client -->|REST API| Coord[Coordinating Node]
Coord --> DN1[Data Node 1]
Coord --> DN2[Data Node 2]
Coord --> DN3[Data Node 3]
MN1[Master Node 1 active] --- MN2[Master Node 2 standby]
MN1 --- MN3[Master Node 3 standby]
DN1 --> P0[Primary Shard 0]
DN1 --> R1[Replica Shard 1]
DN2 --> P1[Primary Shard 1]
DN2 --> R2[Replica Shard 2]
DN3 --> P2[Primary Shard 2]
DN3 --> R0[Replica Shard 0]
Ingest[Ingest Node] -->|pipeline| DN1
4. Inverted Index
Traditional databases index rows → fields. Lucene inverts this: term → list of document IDs.
Tokenization Example
Input text: "The quick brown fox"
Analysis pipeline:
- Char filter — strip HTML, map chars (e.g.,
&→and) - Tokenizer — split on whitespace:
[The, quick, brown, fox] - Token filters — lowercase, stop words, stemming:
[quick, brown, fox](theremoved as stop word)
Resulting inverted index:
| Term | Doc IDs | Positions |
|---|---|---|
quick |
[1, 3] | [1, 2] |
brown |
[1] | [2] |
fox |
[1, 4] | [3] |
When you search for "quick fox":
- Tokenize query →
[quick, fox] - Look up each term in inverted index
- Intersect doc ID lists →
[1, 4]forquick∩[1]forfox=[1] - Score by TF-IDF or BM25
The keyword field type skips analysis — stored as-is for exact matching, sorting, and aggregations.
5. Index Segments
A Lucene index (shard) is composed of immutable segments.
How segments work
- Write: documents first go to an in-memory buffer + translog
- Refresh (default every 1s): memory buffer flushes to a new on-disk segment → document becomes searchable
- Flush (triggered by translog size or time): fsync segments + translog to disk, clear translog
- Merge: background merging combines small segments into larger ones, physically deletes documents marked in the delete bitmap
Why writes are near-real-time, not real-time
The refresh operation (buffer → segment) happens every second by default. Documents are not searchable until the next refresh. For immediate visibility, use refresh=true on the index request (expensive — avoid in bulk).
Deleted documents
ES marks deletes in a bitmap (.del file). The document still occupies disk until a merge physically removes it. _forcemerge compacts this.
Segment merge
Segment 1 (100 docs) ─┐
Segment 2 (80 docs) ─┼─► Merged Segment (170 docs, deletes purged)
Segment 3 (40 docs) ─┘
Merges are CPU/IO intensive. During bulk indexing, set refresh_interval: -1 and number_of_replicas: 0, then restore after.
6. Write Path Sequence Diagram
sequenceDiagram
participant C as Client
participant CN as Coordinating Node
participant PN as Primary Node
participant RN as Replica Node
C->>CN: PUT /index/_doc/1
CN->>CN: Route via hash(doc_id) % num_shards
CN->>PN: Forward to primary shard owner
PN->>PN: Write to translog
PN->>PN: Write to in-memory buffer
PN->>RN: Replicate to replica shard
RN-->>PN: ACK
PN-->>CN: ACK success
CN-->>C: 201 Created
Note over PN: Every refresh_interval (1s default)
PN->>PN: Flush buffer to new Lucene segment
Note over PN: Document now searchable
Note over PN: On flush trigger
PN->>PN: fsync segments + translog
PN->>PN: Clear translog
7. Sharding
Shard routing
shard_id = hash(document_id) % number_of_primary_shards
This is why primary shard count is fixed at index creation — changing it would invalidate the routing formula for all existing documents. To resize, use _reindex into a new index with different shard count, or use _split/_shrink API.
Primary vs Replica
- Primary: handles all writes, replicates to replicas
- Replica: serves read requests, promoted to primary if primary fails
- Replicas are never on the same node as their primary (cluster moves them automatically)
Shard sizing rule
- Target 20–50 GB per shard
- Too small: overhead from too many small Lucene indexes
- Too large: slow recovery, rebalancing is expensive
- Rule of thumb:
num_shards = total_data_size / 30GB
8. Replication
Write flow (sync)
- Client writes to primary shard
- Primary validates and writes locally
- Primary forwards to all in-sync replica shards in parallel
- Waits for all replicas to ACK (controlled by
wait_for_active_shards) - Returns success to client
In-Sync Replicas (ISR)
ES tracks which replicas are "in sync" with the primary. Lagging replicas are removed from ISR. Primary only waits for ISR replicas.
Primary failure
- Master detects primary is down
- Promotes an in-sync replica to new primary
- Assigns a new replica on another node
- Old primary (if it recovers) is fenced — must re-sync before serving writes
9. Cluster States
| State | Meaning |
|---|---|
| 🟢 Green | All primary AND replica shards assigned and active |
| 🟡 Yellow | All primary shards active, but ≥1 replica unassigned |
| 🔴 Red | ≥1 primary shard unassigned — some data unavailable |
What triggers each
Yellow (most common in single-node clusters):
- Only 1 node — no place to put replicas
- Node left the cluster and its replicas are unassigned
- Fix: add nodes, or set
number_of_replicas: 0for dev
Red:
- Node with primary shard(s) is down and no in-sync replica exists
- Corrupt shard data
- Fix: restore from snapshot, or use
_cluster/rerouteto allocate stale replica
# Check cluster health
GET /_cluster/health
# See unassigned shards
GET /_cat/shards?v&h=index,shard,prirep,state,node,unassigned.reason
# Explain why a shard is unassigned
GET /_cluster/allocation/explain
10. Mappings
Dynamic vs Explicit
Dynamic mapping: ES auto-detects types on first document. Risky — a string "123" maps to long, next doc with "abc" fails.
Explicit mapping: Define upfront, prevents surprises in production.
PUT /products
{
"mappings": {
"properties": {
"name": { "type": "text", "analyzer": "english" },
"sku": { "type": "keyword" },
"price": { "type": "float" },
"created_at": { "type": "date", "format": "strict_date_optional_time" },
"tags": { "type": "keyword" },
"location": { "type": "geo_point" },
"embedding": { "type": "dense_vector", "dims": 768 },
"attributes": { "type": "object" },
"variants": {
"type": "nested",
"properties": {
"color": { "type": "keyword" },
"stock": { "type": "integer" }
}
}
}
}
}
Key field types
| Type | Use case |
|---|---|
text |
Full-text search (analyzed, not aggregatable) |
keyword |
Exact match, sort, aggregations (not analyzed) |
date |
Date/datetime, supports math (now-1d) |
object |
Nested JSON object (flattened internally) |
nested |
Array of objects where each object is independently queryable |
geo_point |
Lat/lon for geo distance queries |
dense_vector |
ML embeddings for k-NN/ANN search |
object vs nested
object fields are flattened — cross-field correlation is lost:
variants.color: [red, blue]
variants.stock: [10, 0]
ES cannot tell that red → 10 and blue → 0 are pairs. Use nested when you need to query object arrays as independent documents.
Analyzer chain
"The quick brown fox!"
→ char_filter: strip punctuation → "The quick brown fox"
→ tokenizer: standard → ["The", "quick", "brown", "fox"]
→ token_filter: lowercase → ["the", "quick", "brown", "fox"]
→ token_filter: stop → ["quick", "brown", "fox"]
→ token_filter: stemmer → ["quick", "brown", "fox"]
11. Analysis
Standard analyzer (default)
Tokenizes on whitespace/punctuation, lowercases, removes some punctuation. No stemming.
Custom analyzer
PUT /my_index
{
"settings": {
"analysis": {
"char_filter": {
"html_strip": { "type": "html_strip" }
},
"tokenizer": {
"my_tokenizer": { "type": "standard" }
},
"filter": {
"my_stemmer": { "type": "stemmer", "language": "english" },
"my_stop": { "type": "stop", "stopwords": "_english_" }
},
"analyzer": {
"my_analyzer": {
"type": "custom",
"char_filter": ["html_strip"],
"tokenizer": "my_tokenizer",
"filter": ["lowercase", "my_stop", "my_stemmer"]
}
}
}
}
}
_analyze API — debug tokenization
GET /my_index/_analyze
{
"analyzer": "my_analyzer",
"text": "The <b>Quick</b> Brown Foxes!"
}
Response shows each token, its position, and offset — essential for debugging why a search isn't matching.
12. Query DSL
match — full-text search
GET /products/_search
{
"query": {
"match": {
"name": { "query": "quick brown", "operator": "and" }
}
}
}
term — exact match (keyword fields)
{ "term": { "sku": "ABC-123" } }
range
{ "range": { "price": { "gte": 10, "lte": 100 } } }
{ "range": { "created_at": { "gte": "now-7d/d", "lt": "now/d" } } }
bool — combining queries
{
"query": {
"bool": {
"must": [{ "match": { "name": "laptop" } }],
"filter": [{ "term": { "in_stock": true } }, { "range": { "price": { "lte": 2000 } } }],
"should": [{ "term": { "brand": "apple" } }],
"must_not": [{ "term": { "discontinued": true } }],
"minimum_should_match": 0
}
}
}
filter clauses do not affect relevance score and are cached — always use filter for exact/range matches.
nested
{
"query": {
"nested": {
"path": "variants",
"query": {
"bool": {
"must": [
{ "term": { "variants.color": "red" } },
{ "range": { "variants.stock": { "gt": 0 } } }
]
}
}
}
}
}
function_score — custom relevance
{
"query": {
"function_score": {
"query": { "match": { "name": "laptop" } },
"functions": [
{ "field_value_factor": { "field": "rating", "factor": 1.2, "modifier": "sqrt" } },
{ "gauss": { "created_at": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
],
"score_mode": "multiply",
"boost_mode": "multiply"
}
}
}
script_score — arbitrary scoring
{
"query": {
"script_score": {
"query": { "match_all": {} },
"script": { "source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
"params": { "query_vector": [0.1, 0.2, ...] } }
}
}
}
13. Aggregations
Aggregations run alongside queries. Always use filter context queries to limit the agg dataset.
terms — group by field
{
"aggs": {
"by_brand": {
"terms": { "field": "brand", "size": 10 },
"aggs": {
"avg_price": { "avg": { "field": "price" } }
}
}
}
}
date_histogram — time series
{
"aggs": {
"sales_over_time": {
"date_histogram": { "field": "created_at", "calendar_interval": "1d" },
"aggs": {
"revenue": { "sum": { "field": "price" } }
}
}
}
}
cardinality — unique count (HyperLogLog)
{ "aggs": { "unique_users": { "cardinality": { "field": "user_id", "precision_threshold": 1000 } } } }
Approximate — error ~0.5% at default precision. Exact cardinality requires loading all values into memory.
percentiles — latency distribution
{ "aggs": { "latency_pcts": { "percentiles": { "field": "response_ms", "percents": [50, 95, 99] } } } }
pipeline aggregations
{
"aggs": {
"daily_revenue": {
"date_histogram": { "field": "date", "calendar_interval": "1d" },
"aggs": { "revenue": { "sum": { "field": "price" } } }
},
"revenue_moving_avg": {
"moving_avg": { "buckets_path": "daily_revenue>revenue", "window": 7 }
},
"revenue_derivative": {
"derivative": { "buckets_path": "daily_revenue>revenue" }
}
}
}
14. Performance Tuning
Bulk indexing
Never index one doc at a time. Use _bulk API.
POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "name": "Laptop", "price": 999 }
{ "index": { "_index": "products", "_id": "2" } }
{ "name": "Phone", "price": 699 }
Optimal bulk size: 5–15 MB per request, not by doc count. Test and tune.
Bulk indexing settings
PUT /products/_settings
{
"index": {
"refresh_interval": "-1",
"number_of_replicas": "0"
}
}
After bulk load, restore:
PUT /products/_settings
{
"index": {
"refresh_interval": "1s",
"number_of_replicas": "1"
}
}
POST /products/_forcemerge?max_num_segments=1
doc_values vs fielddata
| doc_values | fielddata | |
|---|---|---|
| Type | keyword, numeric, date |
text |
| Storage | On disk (columnar) | In heap memory |
| Default | Enabled | Disabled |
| Use | Sort, agg, script | Agg on analyzed text (avoid) |
Never enable fielddata: true on text fields in production — causes heap pressure. Instead, use a keyword sub-field for aggregations:
"name": {
"type": "text",
"fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
}
Index sorting
Pre-sort segments for common sort patterns, speeds up queries and early termination:
PUT /logs
{
"settings": {
"index.sort.field": ["@timestamp"],
"index.sort.order": ["desc"]
}
}
Heap sizing
- Set
Xms=Xmx(avoid heap resizing) - Max 31 GB — above this, JVM can't use compressed ordinary object pointers (COOPs), memory usage jumps
- Use 50% of RAM for ES heap, leave the rest for OS page cache (Lucene uses it heavily)
# elasticsearch.yml / jvm.options
-Xms16g
-Xmx16g
Shard sizing
- Target 20–50 GB per shard
- Too many small shards: overhead per shard (metadata, threads, memory)
- Rule:
ceil(total_data_GB / 30) = num_primary_shards - For time-series data: use ILM with rollover to keep shard sizes bounded
15. Common Issues
| Issue | Symptoms | Root Cause | Fix |
|---|---|---|---|
| Yellow cluster | status: yellow |
Replicas unassigned | Add nodes or reduce number_of_replicas |
| Split brain (pre-7.x) | Two master nodes | minimum_master_nodes misconfigured |
ES 7+ uses Raft-based consensus, no config needed |
| High heap usage | GC pressure, slow queries | fielddata enabled, too many shards, large aggs | Disable fielddata, reduce shards, tune circuit breakers |
| Slow queries | High latency on search | Missing filters in bool, using query instead of filter |
Add filter for non-scoring clauses; use _profile API |
| Mapping explosion | Dynamic mapping on high-cardinality keys | Indexing JSON with unknown keys (e.g., user attributes) | Use dynamic: strict, explicit mappings, or flattened type |
| Unassigned shards | Red/yellow cluster | Node left, disk full, shard allocation settings | Check _cluster/allocation/explain, free disk, adjust watermarks |
| Hot shards | One shard at 100% CPU | All docs routing to same shard | Use custom routing, or check if _id is monotonically increasing |
# Profile slow queries
GET /products/_search
{
"profile": true,
"query": { "match": { "name": "laptop" } }
}
# Check circuit breakers
GET /_nodes/stats/breaker
# Check disk watermarks
GET /_cluster/settings
16. Index Lifecycle Management (ILM)
ILM automates moving indices through hot/warm/cold/delete phases based on age or size.
graph LR
Hot[Hot Phase active writes + searches] -->|rollover at 50GB or 30d| Warm[Warm Phase read-only reduced replicas]
Warm -->|after 60d| Cold[Cold Phase mounted from snapshot searchable]
Cold -->|after 180d| Delete[Delete Phase index removed]
ILM Policy
PUT /_ilm/policy/logs_policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": { "max_size": "50gb", "max_age": "30d" },
"set_priority": { "priority": 100 }
}
},
"warm": {
"min_age": "30d",
"actions": {
"shrink": { "number_of_shards": 1 },
"forcemerge": { "max_num_segments": 1 },
"allocate": { "number_of_replicas": 1 },
"set_priority": { "priority": 50 }
}
},
"cold": {
"min_age": "60d",
"actions": {
"searchable_snapshot": { "snapshot_repository": "my_s3_repo" },
"set_priority": { "priority": 0 }
}
},
"delete": {
"min_age": "180d",
"actions": { "delete": {} }
}
}
}
}
Index template with ILM
PUT /_index_template/logs_template
{
"index_patterns": ["logs-*"],
"template": {
"settings": {
"number_of_shards": 2,
"number_of_replicas": 1,
"index.lifecycle.name": "logs_policy",
"index.lifecycle.rollover_alias": "logs"
}
}
}
Bootstrap the first index
PUT /logs-000001
{
"aliases": {
"logs": { "is_write_index": true }
}
}
Write to the logs alias — ILM rolls over automatically creating logs-000002, logs-000003, etc.
Check ILM status
GET /logs-*/_ilm/explain
GET /_ilm/status
Quick Reference
# Cluster health
GET /_cluster/health?level=shards
# Node stats
GET /_nodes/stats?metric=jvm,indices,os
# Index stats
GET /products/_stats
# Pending tasks
GET /_cluster/pending_tasks
# Hot threads
GET /_nodes/hot_threads
# Flush all
POST /_flush
# Force merge (run off-peak)
POST /products/_forcemerge?max_num_segments=1
17. Relevance Scoring — BM25
ES uses BM25 (Best Match 25) by default since ES 5.0. Understanding it explains why results rank the way they do.
BM25 Formula
score(q, d) = Σ IDF(qi) * TF(qi, d)
IDF(t) = log(1 + (N - df + 0.5) / (df + 0.5))
N = total documents in index
df = documents containing term t
High IDF = rare term = more discriminating
TF(t, d) = (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * |d| / avgdl))
freq = term frequency in document
k1 = term frequency saturation (default 1.2) — diminishing returns on repetition
b = field length normalization (default 0.75) — shorter docs rank higher
|d| = document field length
avgdl = average field length across index
Key intuition:
IDF: "laptop" in 100/1M docs scores higher than "the" in 900K/1M docsTF saturation: mentioning "laptop" 10x vs 5x barely matters (k1 controls this)b=0.75: a short product title matching "laptop" ranks higher than a long description matching "laptop"
Tuning scoring
PUT /products/_mapping
{
"properties": {
"name": { "type": "text", "similarity": "BM25", "boost": 3 },
"description": { "type": "text", "similarity": "BM25", "boost": 1 }
}
}
Explain scoring
GET /products/_explain/1
{ "query": { "match": { "name": "laptop" } } }
18. Pagination Strategies
from/size (avoid for deep pagination)
GET /_search
{ "from": 10000, "size": 10, "query": { "match_all": {} } }
ES must fetch from + size docs from every shard, sort them on the coordinating node, then discard the first from. At from=10000, ES processes 10010 docs × N shards. Max default is 10,000 (index.max_result_window).
search_after (recommended for deep pagination)
Uses the sort values of the last result as a cursor. No skip overhead.
GET /_search
{
"size": 20,
"query": { "match": { "category": "electronics" } },
"sort": [{ "price": "asc" }, { "_id": "asc" }], // must include a tiebreaker
"search_after": [999.99, "doc_id_xyz"] // from last page's last hit
}
Point In Time (PIT) — consistent pagination
Results can change between pages if new docs are indexed. PIT freezes a view:
# Open PIT
POST /products/_pit?keep_alive=5m
# Use PIT with search_after
GET /_search
{
"pit": { "id": "<pit_id>", "keep_alive": "5m" },
"sort": [{ "price": "asc" }, { "_id": "asc" }],
"search_after": [999.99, "doc_id_xyz"]
}
# Close PIT when done
DELETE /_pit
{ "id": "<pit_id>" }
scroll (deprecated — use search_after + PIT instead)
Old approach for bulk export. Keeps a search context open server-side. Expensive at scale. Still useful for one-time full data exports:
POST /products/_search?scroll=2m
{ "size": 1000, "query": { "match_all": {} } }
POST /_search/scroll
{ "scroll": "2m", "scroll_id": "<id>" }
19. Ingest Pipelines
Pre-process documents before they're indexed. Runs on ingest nodes.
PUT /_ingest/pipeline/access_log_pipeline
{
"description": "Parse nginx access logs",
"processors": [
{
"grok": {
"field": "message",
"patterns": ["%{IPORHOST:client_ip} .* \\[%{HTTPDATE:timestamp}\\] \"%{WORD:method} %{URIPATHPARAM:path}\" %{NUMBER:status_code:int} %{NUMBER:bytes:long}"]
}
},
{ "date": { "field": "timestamp", "formats": ["dd/MMM/yyyy:HH:mm:ss Z"] } },
{ "geoip": { "field": "client_ip" } },
{ "user_agent": { "field": "user_agent" } },
{ "remove": { "field": "message" } },
{ "set": { "field": "environment", "value": "production" } }
],
"on_failure": [
{ "set": { "field": "_index", "value": "failed-{{ _index }}" } }
]
}
# Test pipeline without indexing
POST /_ingest/pipeline/access_log_pipeline/_simulate
{
"docs": [{ "_source": { "message": "192.168.1.1 - - [01/Jan/2026:12:00:00 +0000] \"GET /api/health HTTP/1.1\" 200 42" } }]
}
# Use pipeline on index
POST /logs/_doc?pipeline=access_log_pipeline
{ "message": "..." }
# Set default pipeline on index
PUT /logs/_settings
{ "index.default_pipeline": "access_log_pipeline" }
Common processors: grok, date, geoip, user_agent, set, remove, rename, convert, split, join, gsub (regex replace), foreach, enrich (lookup from another index), fingerprint (dedup hash).
20. k-NN / Vector Search
Used for semantic search, recommendation, image similarity. Requires dense_vector field.
PUT /articles
{
"mappings": {
"properties": {
"title": { "type": "text" },
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine" // cosine | dot_product | l2_norm
}
}
}
}
Exact k-NN (brute force — small datasets)
GET /articles/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...], // 768 dims
"k": 10,
"num_candidates": 100
}
}
Approximate nearest neighbor (ANN) — uses HNSW index
ES uses HNSW (Hierarchical Navigable Small World) graphs for ANN. Orders of magnitude faster than brute force at scale.
PUT /articles
{
"mappings": {
"properties": {
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine",
"index_options": {
"type": "hnsw",
"m": 16, // connections per node, higher = better recall, more memory
"ef_construction": 100 // size of candidate list during indexing
}
}
}
}
}
Hybrid search — combine BM25 + vector
GET /articles/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "machine learning" } }
]
}
},
"knn": {
"field": "embedding",
"query_vector": [...],
"k": 10,
"num_candidates": 100,
"boost": 0.5
}
}
21. Runtime Fields
Compute fields at query time without reindexing. Useful for prototyping mappings or one-off calculations.
GET /logs/_search
{
"runtime_mappings": {
"response_time_seconds": {
"type": "double",
"script": {
"source": "emit(doc['response_ms'].value / 1000.0)"
}
}
},
"query": {
"range": { "response_time_seconds": { "gt": 1.0 } }
},
"fields": ["response_time_seconds"]
}
Persistent runtime field (added to mapping, no reindex):
PUT /logs/_mapping
{
"runtime": {
"day_of_week": {
"type": "keyword",
"script": { "source": "emit(doc['@timestamp'].value.dayOfWeekEnum.getDisplayName(TextStyle.FULL, Locale.ROOT))" }
}
}
}
22. Cross-Cluster Search (CCS) & Cross-Cluster Replication (CCR)
Cross-Cluster Search — query across multiple clusters
// Configure remote cluster
PUT /_cluster/settings
{
"persistent": {
"cluster.remote.eu_cluster.seeds": ["eu-es-node1:9300"],
"cluster.remote.us_cluster.seeds": ["us-es-node1:9300"]
}
}
// Query across clusters
GET /eu_cluster:logs-*,us_cluster:logs-*,logs-*/_search
{
"query": { "range": { "@timestamp": { "gte": "now-1h" } } }
}
Cross-Cluster Replication — replicate index to another cluster
Used for disaster recovery, geo-distribution, and keeping a read replica in another region.
PUT /follower-logs/_ccr/follow
{
"remote_cluster": "eu_cluster",
"leader_index": "logs-000001",
"settings": {
"number_of_replicas": 1
}
}
Follower index is read-only. Replicates ops from leader in near-real-time. To promote follower to leader (DR failover):
POST /follower-logs/_ccr/pause_follow
POST /follower-logs/_close
POST /follower-logs/_ccr/unfollow
# follower is now a normal writable index
23. Snapshot & Restore
Register a repository (S3)
PUT /_snapshot/my_s3_repo
{
"type": "s3",
"settings": {
"bucket": "my-es-snapshots",
"region": "us-east-1",
"base_path": "elasticsearch/backups"
}
}
Take snapshot
PUT /_snapshot/my_s3_repo/snapshot_2026_01_01
{
"indices": "products,users",
"include_global_state": false,
"metadata": { "taken_by": "ops-team", "reason": "pre-migration" }
}
// Check status
GET /_snapshot/my_s3_repo/snapshot_2026_01_01
Restore
POST /_snapshot/my_s3_repo/snapshot_2026_01_01/_restore
{
"indices": "products",
"rename_pattern": "(.+)",
"rename_replacement": "restored_$1" // restore as "restored_products"
}
Automated snapshots with SLM (Snapshot Lifecycle Management)
PUT /_slm/policy/daily_snapshots
{
"schedule": "0 0 2 * * ?", // daily at 02:00
"name": "<daily-snap-{now/d}>",
"repository": "my_s3_repo",
"config": {
"indices": ["*"],
"include_global_state": true
},
"retention": {
"expire_after": "30d",
"min_count": 5,
"max_count": 30
}
}
// Execute immediately
POST /_slm/policy/daily_snapshots/_execute
24. Security
TLS + Authentication
# elasticsearch.yml
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true
xpack.security.transport.ssl.keystore.path: elastic-certificates.p12
xpack.security.http.ssl.enabled: true
xpack.security.http.ssl.keystore.path: http.p12
# Generate certs
./bin/elasticsearch-certutil ca
./bin/elasticsearch-certutil cert --ca elastic-stack-ca.p12
# Set built-in user passwords
./bin/elasticsearch-setup-passwords interactive
RBAC — Role-based access control
PUT /_security/role/logs_reader
{
"indices": [{
"names": ["logs-*"],
"privileges": ["read", "view_index_metadata"]
}]
}
PUT /_security/user/bob
{
"password": "changeme",
"roles": ["logs_reader"],
"full_name": "Bob Smith"
}
Field-level and document-level security
PUT /_security/role/restricted_reader
{
"indices": [{
"names": ["orders"],
"privileges": ["read"],
"field_security": {
"grant": ["order_id", "status", "created_at"] // only these fields visible
},
"query": "{ \"term\": { \"region\": \"EU\" } }" // only EU docs visible
}]
}
25. Transforms & Rollups
Transforms — materialize aggregations into a new index
PUT /_transform/daily_sales_summary
{
"source": { "index": "orders" },
"dest": { "index": "orders_daily" },
"pivot": {
"group_by": {
"date": { "date_histogram": { "field": "created_at", "calendar_interval": "1d" } },
"category": { "terms": { "field": "category" } }
},
"aggregations": {
"total_revenue": { "sum": { "field": "price" } },
"order_count": { "value_count": { "field": "_id" } }
}
},
"sync": {
"time": { "field": "created_at", "delay": "60s" } // continuous transform
}
}
POST /_transform/daily_sales_summary/_start
Transforms replace rollups (deprecated). Use them for pre-aggregated dashboards, summary indexes, and reducing query load on high-cardinality indexes.