← назад к разделу

Запустить Elasticsearch несложно. Сложнее — сделать так, чтобы он не утонул в собственных данных через месяц. Эта статья о том, как управлять кластером в реальных условиях: контролировать рост индексов, делать резервные копии, правильно выбрать железо и знать, когда что-то идёт не так.

приложение пишет в один alias — по уровням индексы едут сами приложение alias logs hot NVMe, запись warm через 7 дней cold через 30 дней delete через 90 дней rollover: 50 ГБ на шард или 7 дней logs-000001is_write_index logs-000001только чтение logs-000002is_write_index forcemerge, shrink logs-0000011 сегмент 0 реплик logs-000001HDD, редко срок вышел logs-000001 удалён

Приложение знает только 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, которых ещё нет в репозитории.

снимок за 28 июня seg-1 уже лежит, ссылка seg-2 уже лежит, ссылка seg-5 копируется целиком

Снимок - это список файлов сегментов, и копируется из них только тот, которого в репозитории ещё нет; остальные входят в снимок ссылкой на уже лежащие файлы.

Шаг 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 перемещает индексы между уровнями автоматически.

УровеньОборудованиеЧто хранить
HotNVMe, много RAMАктивная запись, последние 1-7 дней
WarmSSDТолько чтение, 7-30 дней
ColdHDD, мало RAM, 0 репликРедкое чтение, 30-90 дней
FrozenSnapshot на 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 / redred — немедленно, 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) — все индексы на этом узле переводятся в режим только для чтения. Запись останавливается.
85 % новых шардов нет 90 % шарды переезжают 95 % только чтение ниже 90 % блок снят сам

Отметки заполнения диска и что кластер делает на каждой; смотреть надо на 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, а потерянный шард восстанавливают из снимка.
  • Предохранители памяти отклоняют тяжёлый запрос и спасают узел: поднимать их порог не надо, надо найти агрегацию по полю с огромной кардинальностью.
  • Медленный лог включают на индексе с порогами по фазам запроса и выборки — это готовый ответ на «что тормозит».
  • Версию обновляют по одному узлу с отключённым перераспределением, снимком до начала, по одной мажорной версии и с зелёным цветом между шагами.

Что почитать дальше