Elasticsearch replicas: 2 to 1 cuts disk a third
Published · OhChimp Engineering

An Elasticsearch index with two replicas stores every shard three times, and moving it to one replica cuts the disk it needs by a third. On a storage-bound cluster that third comes straight off the data tier, but only after you shrink the nodes underneath it.
Why a second replica costs a third of your data tier
A replica is a full copy of a primary shard, held on a different node. Elasticsearch and OpenSearch both default number_of_replicas to 1, so out of the box every index stores two copies of its data. Somebody may have raised yours to 2, often after an outage or a node-loss scare, and now every byte you index is stored three times.
Disk scales with copies. AWS gives the sizing rule for OpenSearch Service in its storage requirements guide: source data times (1 + replicas) times 1.45. The 1.45 folds in about 10% indexing overhead, the 5% Linux reserves for root, and the 20% the service holds back for merges and logs.
Run the rule on 1,000 GiB of source data.
| Replicas | Copies | Minimum storage | gp3 storage at $0.122/GB-month |
|---|---|---|---|
| 2 | 3 | 4,350 GiB | $530.70 |
| 1 | 2 | 2,900 GiB | $353.80 |
That is 1,450 GiB and $176.90 a month of EBS on OpenSearch Service in us-east-1, at the $0.122 gp3 rate on the OpenSearch pricing page. The larger saving is compute, because the data nodes exist to carry that disk.
Say the 4,350 GiB sits on six r6g.xlarge.search data nodes with 725 GiB each, at $0.335 an hour or $244.55 a month per node. At one replica the same data fits on four of those nodes. The data tier goes from $1,998.00 a month (six nodes plus storage) to $1,332.00 (four nodes plus storage), a saving of $666.00, or exactly a third. Dedicated master nodes stay as they are, since they hold no shards.
The CPU side moves too. With document replication, the default on Elasticsearch and OpenSearch, every replica indexes each document again on its own node. Dropping a copy drops a third of the indexing work across the cluster, which is often what lets four nodes carry the indexing load that six did.
The disk you free is still on the bill
Dropping an Elasticsearch index from two replicas to one frees disk within seconds, but the invoice stays the same until you remove nodes or shrink volumes, because provisioned clusters bill for nodes and volume size rather than bytes of index data.
OpenSearch Service bills instance-hours plus the EBS volume size you provisioned on each node. Elastic Cloud bills by the size of each deployment tier, and disk comes bundled with memory at a fixed ratio. A self-managed cluster bills whatever VMs and volumes it runs on. In every case the freed space sits there, provisioned and paid for, until you take nodes or volume size away.
That changes the order of operations. Drop the replica, watch the cluster for a week at one replica, then shrink. Shrinking first is the step you cannot casually undo, because putting the replica back later needs the disk you just gave away.
It also tells you whether the lever pays at all. If your node count is set by heap or CPU rather than disk, freeing storage lets you shrink the volumes and keep the nodes, and the saving is the EBS line alone. Check _cat/allocation before you promise anyone the bigger number.
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_cat/allocation?v&bytes=gb"
A cluster whose disk.percent sits near the 85% low watermark while heap and CPU idle is storage-bound, and that is where dropping a replica turns into dropping nodes.
Templates and lifecycle policies add the replica back
Log and metrics clusters create a new index every day or every rollover, and they are often the fastest-growing clusters you run. Changing the settings on today's indices does nothing for tomorrow's. The new backing index takes its replica count from the matching index template, so a cluster you fixed on Monday is back to two replicas on the indices it creates Tuesday.
Two more places set the count behind your back:
- Lifecycle policies. An Elasticsearch ILM
allocateaction can setnumber_of_replicaswhen an index enters the warm or cold phase. OpenSearch ISM does the same with itsreplica_countaction. A policy that raises replicas on the way to warm quietly undoes your change a week later. auto_expand_replicas. An index with this set, for example0-all, gets a replica count Elasticsearch computes from the number of data nodes. The index settings reference notes that it only takes allocation filtering into account. Set it tofalsein the same call that sets the count, or the count you write gets recomputed.
Data streams add one more trap for anyone filtering by name. Their backing indices are named .ds-<stream>-<date>-<generation>, so a script that skips every index starting with a dot to avoid system indices also skips all of your data stream data.
Keep two replicas on OpenSearch Service Multi-AZ with Standby domains, on read-heavy search indices, and on indices you cannot rebuild and have no snapshot of. OpenSearch Service Multi-AZ with Standby requires at least two replicas for each index, or a multiple of three copies, so the lever does not apply there. Read-heavy search indices use replicas to serve queries, and losing one cuts query capacity on that index. An index you cannot rebuild from source and have no snapshot of needs the extra copy as its only backstop against two nodes failing at once.
Find your over-replicated indices by hand
These commands target Elasticsearch and work against OpenSearch unchanged unless noted. They assume basic auth. For an OpenSearch Service domain that uses IAM signing, swap curl for a SigV4-signing client such as awscurl.
List every index running more than one replica, with the primary size and the disk you get back at one replica. The awk filter skips system indices while keeping data stream backing indices.
curl -s -u "elastic:$ES_PASSWORD" \
"$ES_URL/_cat/indices?h=index,rep,pri.store.size&bytes=mb&expand_wildcards=all&s=pri.store.size:desc" \
| awk '$2 > 1 && ($1 !~ /^\./ || $1 ~ /^\.ds-/) {
freed += $3 * ($2 - 1)
printf "%-60s rep=%s primary=%.1f GiB\n", $1, $2, $3 / 1024
}
END { printf "Disk freed at one replica: %.1f GiB\n", freed / 1024 }'
The freed figure is raw index data. Multiply by about 1.45 for the provisioned storage it lets you give back, the same overhead factor as the sizing rule above.
Find indices whose count is managed for you by auto_expand_replicas. The response lists only indices that set it.
curl -s -u "elastic:$ES_PASSWORD" \
"$ES_URL/*/_settings/index.auto_expand_replicas?expand_wildcards=all&flat_settings=true"
Check which index and component templates pin a replica count, since those decide what the next rollover creates.
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_index_template?flat_settings=true" \
| jq -r '.index_templates[] | [.name, (.index_template.template.settings["index.number_of_replicas"] // "unset")] | @tsv'
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_component_template?flat_settings=true" \
| jq -r '.component_templates[] | [.name, (.component_template.template.settings["index.number_of_replicas"] // "unset")] | @tsv'
Then find lifecycle phases that change replicas. On Elasticsearch, read ILM:
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_ilm/policy" \
| jq -r 'to_entries[] | .key as $p | .value.policy.phases | to_entries[]
| select(.value.actions.allocate.number_of_replicas != null)
| "\($p): \(.key) phase sets \(.value.actions.allocate.number_of_replicas) replicas"'
On OpenSearch, read ISM instead:
curl -s -u "admin:$OS_PASSWORD" "$ES_URL/_plugins/_ism/policies" \
| jq -r '.policies[] | .policy.policy_id as $p | .policy.states[] | .name as $s
| .actions[] | select(.replica_count != null)
| "\($p): \($s) state sets \(.replica_count.number_of_replicas) replicas"'
Last, confirm you have a recent successful snapshot of anything you are about to drop a copy of. One replica survives a single node failure. A snapshot covers the rest.
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_cat/snapshots/my_repo?v&s=end_epoch:desc" | head -5
Drop the replica and shrink with no downtime
Fix the source first, so new indices are born right. Pull the template, change the count, and put it back. A PUT _index_template replaces the whole template, which is why the body comes from a GET rather than being typed fresh. The del drops the timestamps the GET adds, which newer versions reject on the PUT.
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_index_template/logs-app" \
| jq '.index_templates[0].index_template
| del(.created_date, .created_date_millis, .modified_date, .modified_date_millis)
| .template.settings.index.number_of_replicas = "1"' \
> logs-app-template.json
curl -s -u "elastic:$ES_PASSWORD" -X PUT "$ES_URL/_index_template/logs-app" \
-H 'Content-Type: application/json' -d @logs-app-template.json
Edit any ILM allocate or ISM replica_count action you found the same way. Then change the existing indices. number_of_replicas is a dynamic setting, so this applies live with no restart and no reindex. Pointing it at a data stream name applies it to every backing index in the stream.
curl -s -u "elastic:$ES_PASSWORD" -X PUT "$ES_URL/logs-app/_settings" \
-H 'Content-Type: application/json' \
-d '{"index": {"auto_expand_replicas": false, "number_of_replicas": 1}}'
Removing a replica moves no data. Elasticsearch deletes the surplus copies and cluster health stays green throughout. Run _cat/allocation?v again and disk.used should drop by roughly the freed figure from the find step.
Now wait. Give the cluster a full week of normal traffic at one replica, including your heaviest query day, and watch search latency on the indices that lost a copy. Then shrink.
On a self-managed cluster, drain the nodes you are removing and stop them once they hold nothing.
curl -s -u "elastic:$ES_PASSWORD" -X PUT "$ES_URL/_cluster/settings" \
-H 'Content-Type: application/json' \
-d '{"persistent": {"cluster.routing.allocation.exclude._name": "data-5,data-6"}}'
# Repeat until this prints nothing, then stop data-5 and data-6
curl -s -u "elastic:$ES_PASSWORD" "$ES_URL/_cat/shards?h=index,shard,prirep,node" \
| grep -E 'data-5|data-6'
Clear the exclusion afterwards by setting cluster.routing.allocation.exclude._name to null, so a future node reusing those names is not silently skipped.
On OpenSearch Service, change the instance count. Run it with --dry-run first, which reports whether the change needs a blue/green deployment. Keep the count a multiple of your Availability Zone count.
aws opensearch update-domain-config \
--domain-name logs \
--cluster-config InstanceType=r6g.xlarge.search,InstanceCount=4 \
--dry-run
aws opensearch update-domain-config \
--domain-name logs \
--cluster-config InstanceType=r6g.xlarge.search,InstanceCount=4
aws opensearch describe-domain-change-progress --domain-name logs
Rollback depends on which step you are on. Before the shrink, set number_of_replicas back to 2 on the indices and the template. Elasticsearch rebuilds each new copy from its primary over the network at the rate indices.recovery.max_bytes_per_sec allows, and health reads yellow until the copies land. After the shrink, grow the cluster first. Replicas will not allocate to a node above the 85% low disk watermark, so a smaller cluster asked to hold three copies again stays yellow until it has room.
A quick decision tree
graph TD
A[Index with 2 or more replicas] --> B{Multi-AZ with Standby domain?}
B -- Yes --> K[Keep 2 replicas: the domain requires them]
B -- No --> C{Read-heavy search index?}
C -- Yes --> D{Search latency fine on fewer copies in a test?}
D -- No --> K2[Keep the replica for query capacity]
D -- Yes --> E
C -- No --> E{Recent snapshot or rebuildable source?}
E -- No --> F[Take a snapshot first]
F --> G
E -- Yes --> G[Fix templates, ILM or ISM, auto_expand_replicas]
G --> H[Set number_of_replicas to 1]
H --> I{Cluster storage-bound after a week?}
I -- Yes --> J[Remove data nodes and the disk with them]
I -- No --> L[Shrink EBS volume size or keep the headroom]
How OhChimp approaches this
OhChimp connects to your Elasticsearch cluster with a read-only client and reads every index's settings to find the ones running more than one replica. Each one becomes a plan to cut it to one replica, with a risk level and rollback steps, and nothing changes until you press apply. Its node right-sizing check reads per-node heap, CPU and disk, which is where the freed disk turns into a smaller cluster. OhChimp cannot yet measure what a single Elasticsearch plan saves, so the saving stays an estimate and monitoring ends "Inconclusive" rather than VERIFIED. The Elasticsearch integration page covers the other levers it checks, from ILM delete phases to over-sharding.