Zero-Downtime Elasticsearch Cloud Migration: Data Sync, Document Comparison, and Cutover
How do you change the engines on a plane mid-flight? This post walks through a complete zero-downtime Elasticsearch migration to the cloud, covering the core problems: data migration, dual-write synchronization, and a smooth cutover.
How do you swap the engines of a plane while it is flying?
Background
- Business context
- omitted
- Technical context
- The offline cluster has around 40 indexes; the total data volume is small — under 100 GB.
- Elasticsearch carries authentication traffic for the business, so a downtime cutover was not acceptable.
- On top of that, the data in Elasticsearch comes from many different services, pushed at unpredictable times, with no complete re-push mechanism — so downtime was off the table.
- The indexes essentially have no create or update time fields; even where a few had them, they were never used.
- This ruled out Logstash’s incremental sync.
- We wanted a straight swap with no changes to the business services.
- The service is split into a read service and a write service, but the read service can still call write APIs and vice versa.
Architecture
- Full data sync with Logstash
- Script-based comparison to surface the differences, then script-based backfill

Notes:
- The CLB and proxy layer configurations must have redundancy built in.
- If a single CLB cannot carry the load, consider one of the following:
- Option 1: Request multiple CLBs and configure all of their addresses in the application.
- Option 2: Request a single EIP and put multiple CLBs behind it, so the application only needs to know one address.
- Option 3: Upgrade the CLB straight to an NLB.
- CLB documentation
- NLB documentation
- The reason for preparing two sets of CLB and proxy layer: the proxy layer is an Nginx cluster, and updating the configuration on each machine one by one and reloading is slow — and during that window it is unclear which Elasticsearch cluster is receiving the writes.
Core comparison logic
- Fetch all indexes in the offline cluster (skipping system indexes and indexes that do not need to be migrated)
- Iterate over the index set from step 1
- Fetch the document counts of the online and offline indexes; if the totals differ, abort the comparison.
- If the totals match, use
search_afterto page through both the online and offline indexes and compare the documents.
Note: the sort field set for search_after has a few requirements:
- If
_idis the business ID, use that field directly. - If
_idis an ES-generated ID, sort by the business ID field (that field must be unique within the index; if uniqueness cannot be guaranteed, add other fields to make it unique; the point of uniqueness is that the two indexes being compared should have identical documents at the same position — anything different means there is a problem); - If no set of fields can form a composite primary key, pull the entire index into memory, concatenate all fields according to the mapping to build a composite ID, deduplicate, and then compare one by one. (When index counts differ, a similar approach can also be used to find the cause. The reason for this blunt approach: 1. our indexes are small; 2. this comparison program is a temporary tool, not something that will be used long-term.)
Templates, mappings, index settings — all of these need to be compared too.
Core comparison code
MapFlatUtil.java
1 | import java.util.*; |
MapCompareUtil.java
1 | import com.fasterxml.jackson.core.JsonProcessingException; |
SortUtil.java
1 | import java.math.BigDecimal; |
EsQueryUtil.java
1 | public static SearchResponse searchAfterByMultiFields(RestHighLevelClient restHighLevelClient, String indexName, List<String> searchAfterSortFields, List<Object> searchAfterValues, int size) throws IOException { |
Postmortem
- Walk the entire flow end to end with everyone involved, and verify each potential problem together with its response plan.
- Just because something is temporary does not mean you can relax your guard.
- The machines we provisioned for the proxy layer came with two disks: a 50 GB system disk and a 500 GB data disk. But when it came time to actually deploy, the cloud vendor’s engineers still put the Nginx access logs on the system disk, which filled it up and degraded the system.
- We had specifically discussed using that 500 GB disk for access logs, precisely to avoid filling up the machine’s disk.
- We had also listed in the task plan that the proxy layer should send alerts when problems occurred, but we never verified them one by one — so when the system disk filled up, no alert arrived in time.
- Anything on the critical path — temporary or not — must be tested and verified, one item at a time.
- The machines we provisioned for the proxy layer came with two disks: a 50 GB system disk and a 500 GB data disk. But when it came time to actually deploy, the cloud vendor’s engineers still put the Nginx access logs on the system disk, which filled it up and degraded the system.
This article is the English edition of a post originally published in Chinese.