Запустить Elasticsearch несложно. Сложнее — сделать так, чтобы он не утонул в собственных данных через месяц. Эта статья о том, как управлять кластером в реальных условиях: контролировать рост индексов, делать резервные копии, правильно выбрать железо и знать, когда что-то идёт не так.
Приложение знает только alias logs и не знает имён индексов. Когда самый большой первичный шард дорастает до 50 ГБ или индексу исполняется семь дней, rollover заводит следующий индекс и переносит на него признак is_write_index. Прежний индекс с этого момента только читается и дальше едет вниз по возрасту: в warm его сжимают в один сегмент, в cold убирают реплики, на девяностый день удаляют.
Проблема растущих индексов
Представьте, что вы пишете логи в Elasticsearch. Поначалу всё хорошо: поиск быстрый, места хватает. Через полгода диск заполнен, кластер тормозит, логи трёхлетней давности занимают место, но никто их уже не читает.
Решение — Index Lifecycle Management (ILM). Вы описываете политику: как долго индекс живёт на быстром железе, когда переезжает на медленное и дешёвое, когда удаляется. Elasticsearch выполняет её автоматически.
Политика описывает четыре фазы жизни индекса: hot — активная запись на быстрых дисках, warm — только чтение на SSD, cold — редкое чтение без реплик, delete — удаление. Момент перехода задаётся возрастом: ниже это семь, тридцать и девяносто дней.
Есть и пятая фаза — Frozen: данные хранятся как snapshot на S3, доступны для поиска, но читаются в 10-100 раз медленнее. Подходит для аудита или соответствия требованиям регулятора — но прежде, чем закладывать её в план, проверьте лицензию: фаза frozen работает на поисковых снимках (searchable snapshots), а они доступны только в платной подписке Elastic уровня Enterprise. В бесплатной сборке этой фазы у вас не будет: данные придётся держать в cold или выгружать в снимок и восстанавливать оттуда руками.
Как создать политику
Запросы ниже выполняют в консоли Kibana (Dev Tools) или тем же телом через curl к узлу кластера.
PUT /_ilm/policy/logs-policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": { "max_primary_shard_size": "50gb", "max_age": "7d" }
}
},
"warm": {
"min_age": "7d",
"actions": {
"forcemerge": { "max_num_segments": 1 },
"shrink": { "number_of_shards": 1 }
}
},
"cold": {
"min_age": "30d",
"actions": {
"allocate": { "number_of_replicas": 0 }
}
},
"delete": {
"min_age": "90d",
"actions": { "delete": {} }
}
}
}
}
Порог max_primary_shard_size считается по самому большому первичному шарду, а не по всему индексу: для скорости поиска важен размер именно шарда.
И ещё одна деталь той же политики: в фазе warm рядом стоят forcemerge и shrink, но очерёдность задаёт не порядок ключей в JSON — его ILM не смотрит вовсе. Внутри фазы порядок действий фиксированный, и уменьшение числа шардов идёт раньше слияния сегментов.
ILM работает с rollover-индексами: приложение пишет в один alias (logs), а Elasticsearch сам создаёт logs-000001, logs-000002 при достижении порога. Для этого нужны шаблон и стартовый индекс:
PUT /_index_template/logs-template
{
"index_patterns": ["logs-*"],
"template": {
"settings": {
"index.lifecycle.name": "logs-policy",
"index.lifecycle.rollover_alias": "logs"
}
}
}
PUT /logs-000001
{
"aliases": {
"logs": { "is_write_index": true }
}
}
Оговорка про свежие версии: для логов, метрик и вообще всего, что только дописывается, с версии 7.9 вместо алиаса с is_write_index заводят поток данных (data stream). Ротация там встроена, стартовый индекс и алиас руками создавать не надо, а политика вешается прямо на шаблон. Схема выше остаётся рабочей и нужна там, где документы приходится обновлять: поток данных этого не позволяет.
Условия ротации и границы фаз проще увидеть в работе. Программа прогоняет сто дней записи: пока данных много, индекс закрывается по размеру, дальше — по возрасту, и в конце видно, на каком уровне оказался каждый.
живой пример
import java.util.ArrayList;
import java.util.List;
public class IlmRollover {
record Index(String name, int bornDay, double sizeGb, String reason) {}
static String phase(int ageDays) {
if (ageDays >= 90) return "delete";
if (ageDays >= 30) return "cold";
if (ageDays >= 7) return "warm";
return "hot";
}
public static void main(String[] args) {
List<Index> indices = new ArrayList<>();
indices.add(new Index("logs-000001", 0, 0, "пишем"));
for (int day = 1; day <= 100; day++) {
int last = indices.size() - 1;
Index writeIndex = indices.get(last);
double size = writeIndex.sizeGb() + (day <= 40 ? 12 : 2);
int age = day - writeIndex.bornDay();
String reason = size >= 50 ? "размер" : age >= 7 ? "возраст" : "пишем";
indices.set(last, new Index(writeIndex.name(), writeIndex.bornDay(), size, reason));
if (!reason.equals("пишем")) {
String next = String.format("logs-%06d", indices.size() + 1);
indices.add(new Index(next, day, 0, "пишем"));
}
}
for (Index index : indices) {
int age = 100 - index.bornDay();
System.out.printf("%s возраст %3d дн %4.0f ГБ ротация: %-7s уровень: %s%n",
index.name(), age, index.sizeGb(), index.reason(), phase(age));
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
type index struct {
name string
bornDay int
sizeGb float64
reason string
}
func phase(ageDays int) string {
switch {
case ageDays >= 90:
return "delete"
case ageDays >= 30:
return "cold"
case ageDays >= 7:
return "warm"
}
return "hot"
}
func main() {
indices := []index{{"logs-000001", 0, 0, "пишем"}}
for day := 1; day <= 100; day++ {
last := len(indices) - 1
writeIndex := indices[last]
size := writeIndex.sizeGb + 2
if day <= 40 {
size = writeIndex.sizeGb + 12
}
age := day - writeIndex.bornDay
reason := "пишем"
if size >= 50 {
reason = "размер"
} else if age >= 7 {
reason = "возраст"
}
indices[last] = index{writeIndex.name, writeIndex.bornDay, size, reason}
if reason != "пишем" {
indices = append(indices, index{fmt.Sprintf("logs-%06d", len(indices)+1), day, 0, "пишем"})
}
}
for _, ix := range indices {
age := 100 - ix.bornDay
fmt.Printf("%s возраст %3d дн %4.0f ГБ ротация: %-7s уровень: %s\n", ix.name, age, ix.sizeGb, ix.reason, phase(age))
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
function phase(ageDays) {
if (ageDays >= 90) return 'delete';
if (ageDays >= 30) return 'cold';
if (ageDays >= 7) return 'warm';
return 'hot';
}
const indices = [{ name: 'logs-000001', bornDay: 0, sizeGb: 0, reason: 'пишем' }];
for (let day = 1; day <= 100; day++) {
const writeIndex = indices[indices.length - 1];
const size = writeIndex.sizeGb + (day <= 40 ? 12 : 2);
const age = day - writeIndex.bornDay;
const reason = size >= 50 ? 'размер' : age >= 7 ? 'возраст' : 'пишем';
Object.assign(writeIndex, { sizeGb: size, reason });
if (reason !== 'пишем') {
indices.push({ name: 'logs-' + String(indices.length + 1).padStart(6, '0'), bornDay: day, sizeGb: 0, reason: 'пишем' });
}
}
for (const index of indices) {
const age = 100 - index.bornDay;
console.log(`${index.name} возраст ${String(age).padStart(3)} дн ${index.sizeGb.toFixed(0).padStart(4)} ГБ ротация: ${index.reason.padEnd(7)} уровень: ${phase(age)}`);
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
from dataclasses import dataclass
@dataclass
class Index:
name: str
born_day: int
size_gb: float
reason: str
def phase(age_days: int) -> str:
if age_days >= 90:
return "delete"
if age_days >= 30:
return "cold"
if age_days >= 7:
return "warm"
return "hot"
indices = [Index("logs-000001", 0, 0, "пишем")]
for day in range(1, 101):
write_index = indices[-1]
size = write_index.size_gb + (12 if day <= 40 else 2)
age = day - write_index.born_day
reason = "размер" if size >= 50 else "возраст" if age >= 7 else "пишем"
write_index.size_gb, write_index.reason = size, reason
if reason != "пишем":
indices.append(Index(f"logs-{len(indices) + 1:06d}", day, 0, "пишем"))
for index in indices:
age = 100 - index.born_day
print(f"{index.name} возраст {age:3d} дн {index.size_gb:4.0f} ГБ ротация: {index.reason:<7} уровень: {phase(age)}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Резервные копии через snapshots
В Elasticsearch нет аналога pg_dump. Вместо этого — snapshot: копия данных в удалённом хранилище (S3, GCS, Azure Blob или NFS).
Snapshot инкрементальный: первый раз копируется всё, дальше — только новые данные. Под капотом Elasticsearch копирует неизменяемые файлы сегментов Lucene, которых ещё нет в репозитории.
Снимок - это список файлов сегментов, и копируется из них только тот, которого в репозитории ещё нет; остальные входят в снимок ссылкой на уже лежащие файлы.
Шаг 1: зарегистрировать репозиторий
PUT /_snapshot/s3-backup
{
"type": "s3",
"settings": {
"bucket": "my-es-backups",
"region": "eu-west-1",
"compress": true,
"base_path": "es-cluster-1"
}
}
Начиная с Elasticsearch 8 поддержка S3 встроена в дистрибутив; на 7.x её ставили отдельным плагином. На узлах нужна IAM-роль с доступом к bucket.
Шаг 2: создать snapshot
PUT /_snapshot/s3-backup/snapshot-2026-06-27?wait_for_completion=false
{
"indices": "products,orders,logs-*",
"include_global_state": false
}
Шаг 3: восстановить
POST /_snapshot/s3-backup/snapshot-2026-06-27/_restore
{
"indices": "products",
"rename_pattern": "products",
"rename_replacement": "products-restored",
"include_global_state": false
}
Восстановить поверх открытого индекса нельзя — поэтому используем переименование. После восстановления переключаем alias.
Автоматизация через SLM
Чтобы не создавать snapshots вручную, есть Snapshot Lifecycle Management (SLM):
PUT /_slm/policy/daily-snapshots
{
"schedule": "0 30 1 * * ?",
"name": "<daily-snap-{now/d}>",
"repository": "s3-backup",
"config": {
"indices": ["products", "orders"],
"include_global_state": false
},
"retention": {
"expire_after": "30d",
"min_count": 5,
"max_count": 50
}
}
Snapshot каждый день в 01:30, хранится 30 дней, минимум 5 штук, максимум 50.
Многоуровневое хранилище: hot / warm / cold / frozen
В крупных кластерах узлы делят на роли: горячие данные — на быстром дорогом железе, старые — на дешёвом. ILM перемещает индексы между уровнями автоматически.
| Уровень | Оборудование | Что хранить |
|---|---|---|
| Hot | NVMe, много RAM | Активная запись, последние 1-7 дней |
| Warm | SSD | Только чтение, 7-30 дней |
| Cold | HDD, мало RAM, 0 реплик | Редкое чтение, 30-90 дней |
| Frozen | Snapshot на S3, диск как кэш | Аудит, очень редкое чтение |
Роль узла задаётся в elasticsearch.yml:
node.roles: [data_hot, data_content]
# или
node.roles: [data_warm]
Для небольших кластеров (до 10 узлов, до 10 TB) многоуровневость не нужна — усложняет без пользы. Она актуальна при объёмах от 10-20 TB или 50+ узлов.
Как выбрать размер кластера
Heap JVM
Устанавливают в половину от объёма RAM узла. Жёсткий лимит — 31 GB: выше этого Java переключается на другой режим адресации, и выигрыш от большого heap пропадает. Если данных больше — лучше взять два узла по 31 GB heap, чем один с 64 GB.
Идеальный узел: 64 GB RAM — 31 GB под heap и остальное под кэш файловой системы, которым пользуется Lucene.
Шарды на узел
Ориентир: не более 600-800 шардов на узел при 30 GB heap. Каждый шард — накладные расходы на метаданные. Типичная ошибка при первом развёртывании: создать тысячи индексов с пятью шардами каждый и получить 20 000 шардов на 10 узлов.
Если кластер тормозит, а данных мало — почти всегда дело в числе шардов.
Размер одного шарда
Оптимальный диапазон: 10-50 GB. Меньше — лишние накладные расходы, больше — медленный поиск и долгие операции слияния.
Для индекса 1 TB нужно 20-100 первичных шардов. С двумя репликами — 60-300 шардов итого.
Скорость записи
Один узел обрабатывает примерно 5-20 тысяч документов в секунду (зависит от размера документа и настроек). Для 100K документов в секунду нужно 5-20 узлов.
Совет по настройке: по умолчанию refresh_interval=1s, что создаёт много мелких сегментов при высокой нагрузке на запись. Если записей много и свежесть данных не критична, можно поднять до 30s — это даёт в 2-3 раза больше пропускной способности.
Мониторинг кластера
Prometheus exporter
Стандартный инструмент — elasticsearch_exporter. Запускается контейнером рядом с Elasticsearch, опрашивает _nodes/stats и отдаёт метрики в формате Prometheus.
Ключевые метрики
| Метрика | Что означает | Когда тревога |
|---|---|---|
elasticsearch_cluster_health_status | Статус кластера: green / yellow / red | red — немедленно, yellow — расследовать |
elasticsearch_jvm_memory_used_bytes / max_bytes | Использование heap | Устойчиво > 85% |
elasticsearch_jvm_gc_collection_seconds_count | Частота сборки мусора | Old GC чаще 1 раза в минуту |
elasticsearch_indices_indexing_index_time_seconds | Время индексации | Растёт — значит, нарастает нагрузка |
elasticsearch_indices_search_query_time_seconds | Время поиска | Растёт — проблемы с запросами или mapping |
elasticsearch_thread_pool_rejected_count | Отклонённые задачи | Любое значение > 0 |
elasticsearch_filesystem_data_available_bytes | Свободное место на диске | Меньше 15% |
Пороги заполнения диска
Elasticsearch автоматически реагирует на заполнение диска:
- 85% — перестаёт размещать новые шарды на этом узле.
- 90% — начинает переносить шарды на другие узлы.
- 95% (flood stage) — все индексы на этом узле переводятся в режим только для чтения. Запись останавливается.
Отметки заполнения диска и что кластер делает на каждой; смотреть надо на 95 процентов - после этой отметки запись на узле встала, а блок снимется сам только ниже 90.
Flood stage — аварийный режим. Блок read_only_allow_delete Elasticsearch снимает сам, когда заполнение узла опускается ниже высокой отметки в 90%, но освобождать место приходится вручную: расширить диск или удалить старые индексы.
Что нажимать, когда кластер жёлтый или красный
Цвет — это про шарды, и порядок разбора всегда один.
GET /_cluster/health?level=indices
GET /_cat/indices?v&health=yellow&s=index
GET /_cat/shards?v&h=index,shard,prirep,state,unassigned.reason,node
GET /_cluster/allocation/explain
Жёлтый означает: все основные шарды на месте, но какая-то реплика не назначена. Данные целы, запись работает, отказоустойчивость снижена. Самая частая причина на одном узле — индекс с одной репликой при единственном узле (реплику некуда положить): лечится либо вторым узлом, либо number_of_replicas: 0 для локальной установки.
Красный означает: не назначен основной шард, то есть часть данных недоступна. Тут нельзя «подождать»: надо понять причину, и её называет _cluster/allocation/explain — он объясняет по каждому неназначенному шарду, почему его нельзя разместить. Типичные ответы: нет места на диске (сработал предел заполнения), узел с копией не в кластере, версия индекса старше версии узла, ограничение по правилам размещения.
_cat/shards с колонкой причины даёт ту же картину списком, и по ней сразу видно, один индекс пострадал или все.
Отдельный случай — потеря шарда. Если узел с единственной копией основного шарда не вернётся (диск умер), шард не восстановится сам. Варианты два: восстановить индекс из снимка (правильный путь) или согласиться на потерю данных этого шарда и позволить кластеру назначить пустой (_cluster/reroute с явным разрешением потери) — это делают, когда индекс можно переиндексировать из источника. Оба решения принимают осознанно, и оба должны быть описаны заранее.
Частые ошибки
Слишком много шардов. Один индекс на 100 GB лучше, чем 100 индексов по 1 GB.
Динамические поля без ограничений. Если писать JSON с тысячами разных ключей (attr_color, attr_size, attr_brand_...), Elasticsearch заведёт отдельное поле для каждого. До переполнения памяти дело, впрочем, не дойдёт: раньше вы упрётесь в index.mapping.total_fields.limit — по умолчанию тысяча полей на индекс, — и индексация начнёт падать с ошибкой про лимит полей. Именно её вы и увидите в логах. Решение: dynamic: false в mapping и тип flattened для произвольных атрибутов.
Крупные aggregations. terms aggregation с size: 10000 на миллиардах документов может убить узел. Вместо этого используйте composite aggregation с постраничной загрузкой.
Отключённый _source. Можно сэкономить место, убрав _source из индекса. Но тогда невозможно обновить документ или переиндексировать — только полное воссоздание из источника. Подходит только для логов и метрик, где исходные данные хранятся где-то ещё.
Глубже: force merge: зачем и когдарасширенное
Каждый шард Elasticsearch состоит из нескольких сегментов — файлов на диске. Новые данные пишутся в новые сегменты. Если их накопилось сто — на каждый запрос ES читает сто файлов, это медленнее.
После того как индекс перестаёт получать новые записи (прошёл rollover), его можно «сжать» в один сегмент:
POST /logs-000001/_forcemerge?max_num_segments=1
Эффект: чтение ускоряется, метаданные занимают меньше памяти. Насколько именно ускорится — зависит от того, из скольких сегментов шард был собран: из сотни в один разница заметная, из трёх в один — почти никакой.
Важно: force merge — тяжёлая операция, она нагружает диск и CPU, может занять часы. Не запускайте её на активно пишущем индексе. ILM делает force merge автоматически в warm-фазе в нужный момент.
Глубже: предохранители памятирасширенное
«Крупная агрегация может убить узел» — верно лишь отчасти: у Elasticsearch есть предохранители, которые не дают этому случиться. Они считают, сколько памяти запрос собирается занять, и отклоняют его заранее с ошибкой о срабатывании предохранителя.
Их несколько: общий (порядка 95 % кучи), для запросов (indices.breaker.request, около 60 %), для полевых данных, для входящих запросов. Ошибка предохранителя — это защита, а не поломка: узел остался жив, упал один запрос. Правильная реакция — не поднимать порог, а разобраться, какой запрос столько просит: обычно это агрегация по полю с огромным числом уникальных значений или сортировка по анализируемому тексту.
Смотреть их состояние: GET /_nodes/stats/breaker показывает по каждому предохранителю предел, текущее значение и число срабатываний. Рост числа срабатываний — сигнал, который стоит вывести в мониторинг.
Глубже: медленный лограсширенное
Самый дешёвый способ найти тяжёлые запросы: кластер сам пишет в журнал те, что выполнялись дольше порога. Включается на индексе, отдельно для фазы запроса и фазы выборки:
PUT /products/_settings
{
"index.search.slowlog.threshold.query.warn": "2s",
"index.search.slowlog.threshold.query.info": "500ms",
"index.search.slowlog.threshold.fetch.warn": "1s",
"index.indexing.slowlog.threshold.index.warn": "1s"
}
В журнале появится сам запрос, его время и шард — то есть готовый ответ на «что именно тормозит». Разделение на фазы полезно: медленная фаза запроса означает дорогой поиск (агрегации, nested, нечёткость), медленная фаза выборки — что дорого собирать документы (большие _source, подсветка по огромным текстам).
Порог ставят заведомо выше нормального времени ответа, иначе журнал заполнится обычными запросами. И помнят, что запись в него стоит места: на нагруженном кластере включают на время разбора.
Глубже: обновление версии кластерарасширенное
Регулярная процедура, которую стоит описать заранее, потому что делать её придётся часто.
Обновление идёт по одному узлу, без остановки кластера. Порядок такой: отключить перераспределение шардов (чтобы кластер не начал копировать данные на время, пока узел выключен), остановить узел, обновить, запустить, дождаться, пока он вернётся в кластер и его шарды снова станут назначенными, включить перераспределение — и только потом переходить к следующему.
PUT /_cluster/settings
{ "persistent": { "cluster.routing.allocation.enable": "primaries" } }
# ... обновление узла ...
PUT /_cluster/settings
{ "persistent": { "cluster.routing.allocation.enable": null } }
Три обязательных условия. Снимок до начала: обновление — это операция без отката. Совместимость версий: перескакивать мажорные версии нельзя, обновляются по одной, и индексы, созданные слишком старой версией, придётся переиндексировать. И зелёный цвет кластера между узлами: обновлять следующий узел, пока предыдущий не восстановился, — способ получить красный кластер.
Клиентские библиотеки обновляют отдельно и обычно после кластера: новый клиент с старым кластером работает хуже, чем наоборот.
Коротко
- ILM описывает политику жизни индекса: hot → warm → cold → delete. Для логов, метрик и событий — обязателен.
- Rollover закрывает индекс по размеру первичного шарда или по возрасту; приложение всегда пишет в один alias.
- Snapshots — единственный способ резервного копирования: инкрементальны, лежат в S3 / GCS / Azure, по расписанию их создаёт и чистит SLM.
- Hot/warm/cold нужен от ~10 TB данных; в небольших кластерах — лишнее.
- Heap — не больше 31 GB, шардов — не больше 600-800 на узел, размер шарда — 10-50 GB.
- Flood stage на 95% диска останавливает запись; блок снимется сам ниже 90%, но место освобождать придётся вручную.
- Жёлтый цвет — не назначена реплика (данные целы), красный — не назначен основной шард; причину называет
_cluster/allocation/explain, а потерянный шард восстанавливают из снимка. - Предохранители памяти отклоняют тяжёлый запрос и спасают узел: поднимать их порог не надо, надо найти агрегацию по полю с огромной кардинальностью.
- Медленный лог включают на индексе с порогами по фазам запроса и выборки — это готовый ответ на «что тормозит».
- Версию обновляют по одному узлу с отключённым перераспределением, снимком до начала, по одной мажорной версии и с зелёным цветом между шагами.
Что почитать дальше
- Основы Elasticsearch — как устроен кластер, шарды и реплики.
- Query DSL и релевантность — как писать эффективные запросы.
- Клиентский код Elasticsearch — интеграция на стороне приложения.
- Поиск: PostgreSQL FTS или Elasticsearch — когда отдельный кластер вообще не нужен.