OpenSearch: индексы, маппинги и шаблоны

Как устроены индексы и маппинги в OpenSearch: dynamic vs explicit, типы полей и анализаторы, index и component templates, алиасы для ротации — с живой проверкой на 3.5.0

Кластер поднят и защищён (предыдущая статья серии) — но пустой кластер бесполезен, пока в нём нет индексов с продуманной структурой. От того, как спроектирован маппинг, зависит и качество поиска, и объём хранения, и то, насколько больно будет менять схему потом: у существующего индекса нельзя переопределить тип поля без reindex.

OpenSearch: индексы, маппинги и шаблоны

В статье

О версии. Примеры в статье проверены на OpenSearch 3.5.0 — том же стенде, что и в предыдущей статье серии.

Индекс, шард, реплика

Индекс в OpenSearch — логическая единица: именованная коллекция документов одной предметной области (app-logs-2026.07, service-a-events), к которой приложение обращается по имени или по алиасу. Но физически документы индекса не лежат одним куском на одной ноде — они распределены по шардам, и именно шард, а не индекс, является единицей хранения и распределения данных в кластере. Каждый шард — это отдельный экземпляр Lucene-индекса со своими сегментами на диске; OpenSearch поверх них показывает единый логический индекс, сам решая, на какой шард отправить запись и с каких шардов собрать результат поиска.

У шардов есть два вида: primary и replica. Primary-шард — основная копия данных, на которую сначала попадает запись; replica-шард — копия primary на другой ноде, которая обслуживает чтение и подхватывает роль primary, если нода с оригиналом выходит из строя. Именно наличие или отсутствие живых реплик определяет статус кластера из предыдущей статьи серии: green означает, что назначены не только все primary-шарды, но и все их реплики; yellow — что primary-шарды на месте, а часть реплик ещё не разъехалась по нодам (например, кластер только что создал индекс и ещё не успел ребалансироваться, либо реплик физически негде разместить — не хватает нод). Без единой живой реплики потеря ноды с primary-шардом означает потерю данных этого шарда безвозвратно, поэтому number_of_replicas: 0 — это осознанный компромисс ради экономии места, а не настройка по умолчанию для чего-то важнее черновика.

flowchart TB subgraph idx["Индекс app-logs-2026.07"] P0["primary 0"] P1["primary 1"] end subgraph n1["Нода 1"] A["primary 0"] B["replica 1"] end subgraph n2["Нода 2"] C["primary 1"] D["replica 0"] end P0 -. "распределяется" .-> A P1 -. "распределяется" .-> C

flowchart TB
    subgraph idx["Индекс app-logs-2026.07"]
        P0["primary 0"]
        P1["primary 1"]
    end
    subgraph n1["Нода 1"]
        A["primary 0"]
        B["replica 1"]
    end
    subgraph n2["Нода 2"]
        C["primary 1"]
        D["replica 0"]
    end
    P0 -. "распределяется" .-> A
    P1 -. "распределяется" .-> C
Индекс, его primary-шарды и реплики по нодам

На схеме у индекса два primary-шарда, и каждый уходит на свою ноду, а рядом на соседней ноде размещается его реплика — так при потере любой одной ноды у обоих primary-шардов остаётся живая копия на оставшейся ноде.

Число primary-шардов индекса — параметр number_of_shards, и он фиксируется в момент создания индекса. Задним числом его увеличить или уменьшить нельзя: документы уже распределены по конкретному числу шардов через хеш от _id, и добавление ещё одного primary-шарда сломало бы эту раскладку для всех уже записанных данных. Единственный способ изменить число primary-шардов — создать новый индекс с нужным number_of_shards и переиндексировать данные (_reindex) из старого. Это первая, но не последняя причина, по которой в статье будет регулярно всплывать один и тот же совет: продумывать sizing и структуру индекса до того, как в него ушёл первый документ, а не подгонять постфактум. Число replica-шардов (number_of_replicas), в отличие от primary, — настройка, которую можно менять на лету в любой момент, без reindex и без простоя.

Первый документ и dynamic mapping

Самый быстрый способ начать работать с OpenSearch — вообще не думать про схему. Индекс app-logs-demo ещё не существует, никакого маппинга нет, но документ можно отправить прямо так:

POST app-logs-demo/_doc/1
{"level":"error","message":"disk full on node","status":500,"ts":"2026-07-03T10:00:00Z","latency_ms":12.7}

OpenSearch не откажет: индекс создастся неявно, а для каждого поля документа механизм dynamic mapping сам угадает тип по значению JSON. Вот что реально получилось на стенде 3.5.0, если сразу после запроса спросить _mapping:

{
  "app-logs-demo" : {
    "mappings" : {
      "properties" : {
        "latency_ms" : {
          "type" : "float"
        },
        "level" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "message" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 256
            }
          }
        },
        "status" : {
          "type" : "long"
        },
        "ts" : {
          "type" : "date"
        }
      }
    }
  }
}

Разберём поле за полем, что здесь произошло и где угадывание уже не совпадает с тем, что нужно.

Строки level и message стали text с под-полем .keyword. Для любой JSON-строки dynamic mapping создаёт не один тип, а сразу два: основное поле text (проходит через анализатор, разбивается на токены — годится для полнотекстового поиска) и вложенное level.keyword типа keyword (хранится как есть, без анализа — годится для точных фильтров, агрегаций и сортировки). Это multi-field: значение физически хранится дважды, в двух разных структурах Lucene. Для message, который действительно текст, второй экземпляр почти всегда бесполезен — точное совпадение по полному тексту лог-строки нужно редко. Для level, у которого на практике 4-5 фиксированных значений (error, warn, info, debug), всё наоборот: полнотекстовый анализ ему не нужен вовсе, а вот .keyword — это как раз то единственное представление, которое имеет смысл. Dynamic mapping не знает про эту разницу и одинаково удваивает оба поля.

Здесь же прячется деталь, о которой стоит помнить отдельно: ignore_above: 256 у .keyword-под-полей — значение по умолчанию. Если в message попадёт строка длиннее 256 символов, значение выше этой длины в .keyword-поле не запишется вовсе (сам документ не отклоняется, но точный поиск/фильтр/агрегация по такому полю для этой записи не сработают) — а для лог-сообщений это далеко не редкий случай.

status стало long, а не short или integer. Значение 500 прекрасно поместилось бы в short (до 32767) или integer, но dynamic mapping для любого целого числа в JSON всегда выбирает самый широкий доступный тип — long, 8 байт на значение. Логика простая и осторожная: угадывающий механизм не может знать, вырастет ли в будущих документах это же поле до значений за пределами short, и не хочет упереться в переполнение. Плата за эту осторожность — в разы больше места на диске при миллионах документов, если реальный диапазон значений — коды состояния 100-599, вообще-то укладывающиеся в short.

ts определилось как date. Сработала date-детекция: строка в формате, похожем на ISO 8601 (2026-07-03T10:00:00Z), была опознана и превращена в date, а не осталась text/keyword. Это тот редкий случай, когда угадывание попало точно — но это везение, а не гарантия: стоит формату временной метки чуть отклониться от распознаваемых шаблонов, и то же самое поле в следующем документе может определиться уже как text, и тогда сортировка и range-запросы по времени в разных документах индекса начнут вести себя по-разному.

latency_ms стало float. Для чисел с плавающей точкой в JSON dynamic mapping выбирает float — тоже разумное значение по умолчанию для метрик вроде задержки в миллисекундах, но опять же без учёта того, какая точность и диапазон реально нужны в конкретном поле.

Главная опасность dynamic mapping — не в неточности отдельных типов, а в том, что он в принципе не задаёт границ структуре документа. Если следующий документ в тот же индекс придёт с полем region, которого не было раньше, OpenSearch без единого предупреждения добавит его в маппинг как ещё одно поле — и так для любого нового ключа в любом документе. На проде с непредсказуемыми или генерируемыми на лету ключами (частая ошибка — класть произвольные HTTP-заголовки или произвольные атрибуты события прямо в корень документа) это превращается в mapping explosion: маппинг индекса разрастается до тысяч полей, кластер тратит память на хранение метаданных каждого поля, а _mapping конкретного индекса перестаёт помещаться в разумный размер ответа. Дальше в статье — раздел «Контроль над схемой» о том, как ограничить этот процесс через dynamic: strict, а прямо сейчас — что должно быть в маппинге, чтобы до угадывания дело не доходило.

Явный маппинг: типы полей

Явный маппинг — это тот же properties, что и выше, только объявленный заранее, до первого документа, и вручную. У существующего поля тип нельзя поменять задним числом (только создать новый индекс и переиндексировать), поэтому явный маппинг — это ответственность, которую стоит взять на себя один раз в начале, а не разгребать потом на проде.

keyword против text. Это разделение — центральное для любого маппинга в OpenSearch, и лучше решить его осознанно для каждого строкового поля, а не отдавать на волю dynamic mapping:

  • keyword хранит значение как атомарную единицу, без анализа. Подходит для точного совпадения (term-запросы), фильтров, агрегаций (terms-агрегация по level — посчитать, сколько записей каждого уровня) и сортировки. Значения error, warn, info — типичный keyword.
  • text пропускает значение через анализатор (токенизация, приведение к нижнему регистру и так далее — подробно в разделе «Анализаторы»), что даёт полнотекстовый поиск по словам внутри строки. Подходит для message, заголовков статей, произвольного текста, где нужно искать по вхождению слова, а не по точному совпадению всей строки. У text-полей по умолчанию нет keyword-агрегации — попытка агрегировать по чистому text-полю вернёт ошибку Fielddata is disabled on text fields.
{
  "properties": {
    "level":   { "type": "keyword" },
    "message": { "type": "text" }
  }
}

Числовые типы — и зачем сужать. OpenSearch различает long, integer, short, byte (целые разной ширины) и float, double, half_float, scaled_float (дробные). В отличие от dynamic mapping, который всегда берёт самый широкий тип на всякий случай, в явном маппинге стоит выбирать тип под реальный диапазон значений поля: status с HTTP-кодами 100-599 умещается в short (2 байта) вместо long (8 байт) — на индексе с миллионами записей это прямая экономия места на диске и в памяти при агрегациях по этому полю. latency_ms как float (4 байта) обычно достаточно точен для миллисекунд задержки; double имеет смысл, только если нужна точность за пределами того, что даёт одинарная точность IEEE 754.

{
  "properties": {
    "status":     { "type": "short" },
    "latency_ms": { "type": "float" }
  }
}

date и boolean. date по умолчанию понимает ISO 8601 и epoch millis/seconds; для нестандартных форматов временных меток формат указывается явно через format, а не отдаётся на откуп date-детекции, как в dynamic mapping. boolean принимает true/false, а также "true"/"false" строкой — и это единственный тип, где OpenSearch сам приводит строковое значение к логическому без явного анализатора.

{
  "properties": {
    "ts":       { "type": "date" },
    "resolved": { "type": "boolean" }
  }
}

object против nested. Оба типа описывают вложенную структуру — документ внутри документа, — но хранят её по-разному. object (тип по умолчанию для вложенного JSON-объекта) при индексации уплощается: все под-поля вложенного объекта или массива объектов превращаются во внутреннем представлении Lucene в плоский набор значений на уровне родительского документа, и связь между полями одного элемента массива теряется. Классический пример: массив [{"user":"alice","role":"admin"},{"user":"bob","role":"viewer"}] как object даёт в индексе фактически user: [alice, bob] и role: [admin, viewer] отдельно друг от друга — запрос «найти документ, где user=alice И role=viewer» по такому полю ошибочно совпадёт, хотя в реальных данных alice была admin, а не viewer.

nested устраняет эту проблему: каждый элемент массива индексируется как отдельный скрытый документ Lucene, и nested-запрос (nested query) ищет совпадение полей внутри одного элемента, а не по всему полю сразу. Плата — nested дороже по индексации и по запросам (под капотом это join между скрытыми документами), поэтому применять его стоит только там, где действительно важна связь полей внутри элемента массива, а не для одиночных вложенных объектов без массивов.

{
  "properties": {
    "tags_flat":   { "type": "object" },
    "assignments": {
      "type": "nested",
      "properties": {
        "user": { "type": "keyword" },
        "role": { "type": "keyword" }
      }
    }
  }
}

Multi-field: text + .keyword осознанно, а не по умолчанию. Сам приём, который dynamic mapping применяет автоматически ко всем строкам, годится и в явном маппинге — только теперь его ставят туда, где он действительно нужен: message ищется полнотекстово, но иногда нужна точная группировка по идентичным сообщениям или сортировка по .keyword-варианту. Разница с dynamic mapping — в осознанном ignore_above (при длинных сообщениях лучше явно поднять лимит, если точное совпадение по полному тексту важно) и в том, что multi-field заводится только там, где оба представления реально используются, а не на каждом строковом поле подряд.

{
  "properties": {
    "message": {
      "type": "text",
      "fields": {
        "keyword": { "type": "keyword", "ignore_above": 512 }
      }
    }
  }
}

Собранный целиком, этот набор полей — ts как date, level/service как keyword, message как text с .keyword-под-полем, status как short, latency_ms как float — не абстрактный пример: это ровно component template logs-common из демо-стенда к статье (opensearch/indices-mappings/ в digital-cookbook), который дальше в разделе «Index templates и component templates» применяется автоматически ко всем индексам app-logs-*.

Анализаторы

Когда поле объявлено как text, значение при индексации не попадает в Lucene как есть — оно проходит через анализатор: цепочку из character filter (правки текста до токенизации), tokenizer (разбиение строки на токены) и token filter (правки уже готовых токенов — например, приведение к нижнему регистру). Именно результат этой цепочки, а не исходная строка, ложится в инвертированный индекс и участвует в полнотекстовом поиске.

Проверить, что именно сделает анализатор с конкретной строкой, можно напрямую через API _analyze, не создавая индекс. Вот реальный ответ стенда 3.5.0 для строки Disk Full on os-node-1 со стандартным анализатором:

{"tokens":[{"token":"disk","start_offset":0,"end_offset":4,"type":"<ALPHANUM>","position":0},{"token":"full","start_offset":5,"end_offset":9,"type":"<ALPHANUM>","position":1},{"token":"on","start_offset":10,"end_offset":12,"type":"<ALPHANUM>","position":2},{"token":"os","start_offset":13,"end_offset":15,"type":"<ALPHANUM>","position":3},{"token":"node","start_offset":16,"end_offset":20,"type":"<ALPHANUM>","position":4},{"token":"1","start_offset":21,"end_offset":22,"type":"<NUM>","position":5}]}

Что здесь произошло. Standard analyzer (тип по умолчанию для text, если ничего явно не задано) сначала привёл весь текст к нижнему регистру — Disk и Full стали disk и full, поэтому поиск по full найдёт документ независимо от регистра в исходной строке. Дальше tokenizer разбил строку по границам слов, а заодно — по дефису: os-node-1 превратилось не в один токен, а в три отдельных: os, node, 1 (два <ALPHANUM> и один <NUM> по типу). Это значит, что запрос os-node-1 как полнотекстовый матч на самом деле ищет документы, где встречаются токены os, node и 1 — не обязательно рядом и не обязательно в этом порядке, если явно не указан match_phrase. Для идентификаторов с дефисами, версий пакетов, k8s-имён подов это частый источник удивления: кажется, что ищется точная строка, а фактически ищутся отдельные части, разбитые токенизатором по любому не-буквенно-цифровому символу.

Стандартный анализатор — разумный дефолт для естественного текста на большинстве языков, но не единственный вариант: под конкретную задачу можно собрать кастомный анализатор из своих tokenizer и filter — например, keyword tokenizer (не бьёт строку вовсе, весь текст — один токен) в паре с lowercase filter, если нужен регистронезависимый, но не разбитый на части поиск, либо анализатор с edge_ngram для автодополнения. Углубляться в конструирование кастомных анализаторов в этой статье не будем — важно, что стандартный анализатор не единственный, и когда токенизация «съедает» смысл идентификатора (как с os-node-1 выше), решение — не отказ от text, а либо кастомный анализатор, либо соседний keyword-вариант поля.

И вот тут в игру вступает multi-field из предыдущего раздела уже не как приятная опция, а как необходимость. Агрегации, сортировка и точные фильтры работают через doc_values — колоночную структуру на диске, которая для keyword-полей строится автоматически. Для text-полей doc_values не строятся: вместо них есть fielddata, которая по умолчанию отключена, потому что держит весь набор токенов в памяти кучи и на больших объёмах данных легко приводит к OOM. Поэтому агрегация или сортировка прямо по text-полю не «работает медленнее» — она вообще не работает и возвращает ошибку Fielddata is disabled on text fields by default. Отсюда практическое правило: полнотекстовый поиск — по text (message, level до анализа), а группировка, сортировка, terms-агрегация, точный фильтр — по .keyword-под-полю (message.keyword, level.keyword). Это не два варианта на выбор, а два разных инструмента для двух разных операций над одним и тем же полем.

Контроль над схемой

Раздел «Первый документ и dynamic mapping» закончился предупреждением про mapping explosion: без ограничений индекс молча принимает любое новое поле в любом документе. Параметр dynamic в маппинге — это и есть переключатель, которым это поведение переводится из «как получится» в «как задумано». У него три осмысленных значения.

dynamic: true — поведение по умолчанию, то самое угадывание из раздела про dynamic mapping. Новое поле в документе автоматически добавляется в маппинг и сразу индексируется. Удобно для прототипа, опасно на проде с непредсказуемой структурой документов — именно это значение приводит к mapping explosion.

dynamic: false — новое поле сохраняется в _source (то есть видно в исходном документе при выдаче) и не отклоняет запись, но не добавляется в маппинг и не индексируется. По такому полю нельзя ни искать, ни фильтровать, ни агрегировать — оно как будто не существует для поискового движка, хотя физически лежит в документе. Полезно, когда в документ иногда прилетают произвольные метаданные, которые нужно хранить для истории, но не нужно делать доступными для поиска.

dynamic: strict — самый жёсткий режим: документ с полем, которого нет в явном маппинге, отклоняется целиком, ни одно его поле не записывается. Это и есть режим, в котором был resolve template из предыдущего раздела статьи ("dynamic" : "strict" в ответе _simulate_index). Вот что реально происходит на стенде 3.5.0, если в индекс с таким маппингом отправить документ с незнакомым полем region:

{"error":{"root_cause":[{"type":"strict_dynamic_mapping_exception","reason":"mapping set to strict, dynamic introduction of [region] within [_doc] is not allowed"}],"type":"strict_dynamic_mapping_exception","reason":"mapping set to strict, dynamic introduction of [region] within [_doc] is not allowed"},"status":400}

HTTP 400, документ не записан — ни поле region, ни остальные поля того же документа. Это осознанный компромисс: strict защищает от mapping explosion и от тихого расползания схемы, но требует, чтобы маппинг действительно покрывал все поля, которые приложение когда-либо отправит, — иначе первый же непредвиденный ключ роняет запись целиком, а не просто игнорирует лишнее.

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "ts":      { "type": "date" },
      "level":   { "type": "keyword" },
      "service": { "type": "keyword" },
      "message": {
        "type": "text",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 512 }
        }
      },
      "status":     { "type": "short" },
      "latency_ms": { "type": "float" }
    }
  }
}

Между «отклонить всё непредвиденное» (strict) и «угадывать как попало» (true) есть третий путь — dynamic_templates: правила, которые говорят OpenSearch, каким типом индексировать новое поле, если оно подходит под шаблон по имени или по значению. Это не отменяет dynamic mapping, а делает его предсказуемым: поле всё ещё появляется в маппинге автоматически, но с заранее выбранным типом, а не с тем, что угадает detection по умолчанию.

{
  "mappings": {
    "dynamic_templates": [
      {
        "ids_as_keyword": {
          "match": "*_id",
          "mapping": { "type": "keyword" }
        }
      },
      {
        "timestamps_as_date": {
          "match": "*_at",
          "mapping": { "type": "date" }
        }
      }
    ]
  }
}

С таким набором шаблонов любое новое поле вида trace_id или user_id попадёт в маппинг как keyword (а не как text с .keyword-под-полем, которое dynamic mapping навесил бы по умолчанию на строку), а любое created_at или resolved_at — как date, даже если формат значения не будет однозначно опознан date-детекцией. dynamic_templates можно сочетать со строгим режимом, но не через обычный strict: под ним новое поле отклоняется, даже если совпадает с шаблоном (проверено на 3.5.0 — документ с trace_id в индекс с dynamic: strict даёт strict_dynamic_mapping_exception, несмотря на подходящий dynamic_templates). Для сценария «строгий верхний уровень плюс узкая лазейка для шаблонных полей» в OpenSearch 3.x есть отдельное значение — dynamic: strict_allow_templates: поле, совпавшее с одним из dynamic_templates, добавляется с типом из шаблона, а всякое другое непредвиденное поле по-прежнему отклоняется (dynamic introduction of [region] ... is not allowed). Есть и более мягкий парный режим — dynamic: false_allow_templates: непредвиденное поле, не совпавшее ни с одним шаблоном, не отклоняет запись, а просто остаётся в _source неиндексированным (как при обычном false), тогда как совпавшее с шаблоном — добавляется в маппинг с нужным типом (проверено на 3.5.0). Вместе с явными properties для известных полей любой из этих двух режимов закрывает основной поток данных предсказуемой схемой, оставляя щель ровно для полей, чьё точное имя заранее не известно, но подчиняется соглашению об именовании.

Index templates и component templates

Маппинг из раздела «Контроль над схемой» писался руками для одного конкретного индекса. Но app-logs-* — не один индекс, а семейство: app-logs-2026.07, app-logs-2026.08, app-logs-2026.09 и так далее, если индексы ротируются по времени (к ротации вернёмся в следующем разделе). Копировать один и тот же mappings в каждый новый индекс вручную — верный способ через полгода получить в семействе индексов с потихоньку разъехавшейся схемой. Для этого в OpenSearch есть шаблоны, и их два вида, соединённых в одну цепочку.

Component template — переиспользуемый блок настроек и/или маппингов, который сам по себе ни к одному индексу не привязан. Он существует отдельно, как заготовка: набор полей, общий для нескольких разных семейств индексов, или блок настроек шардов, который хочется переиспользовать. Вот component template logs-common из демо-стенда к статье — ровно тот набор полей, который в разделе «Явный маппинг» собирался вручную:

{
  "template": {
    "mappings": {
      "properties": {
        "ts":         { "type": "date" },
        "level":      { "type": "keyword" },
        "service":    { "type": "keyword" },
        "message":    { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 512 } } },
        "status":     { "type": "short" },
        "latency_ms": { "type": "float" }
      }
    }
  }
}

Index template — то, что действительно применяется к индексам: он содержит index_patterns (по какой маске имени индекса срабатывает), composed_of (список component templates, чьи блоки сливаются в итоговый маппинг и настройки) и, если нужно, собственные settings/mappings поверх унаследованных из component templates. Вот index template app-logs из того же демо-стенда:

{
  "index_patterns": ["app-logs-*"],
  "composed_of": ["logs-common"],
  "priority": 100,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    },
    "mappings": {
      "dynamic": "strict"
    }
  }
}

Как только оба шаблона зарегистрированы, дальнейшее — не забота приложения. Индекс app-logs-2026.08 ещё не существует; первый же запрос, создающий его (явный PUT или неявное создание при первой записи), подпадает под index_patterns: ["app-logs-*"] — и OpenSearch перед фактическим созданием индекса сам сливает settings/mappings из index template с блоками из всех composed_of, применяя итог. Приложению не нужно ничего знать про шаблоны: оно просто пишет в app-logs-2026.08, и индекс появляется уже с нужной схемой.

Поле priority решает конфликт, если под один и тот же вновь создаваемый индекс подходит больше одного index template с пересекающимися index_patterns — побеждает шаблон с большим значением priority, и применяется только он (в отличие от composed_of, где блоки нескольких component templates действительно сливаются друг с другом по порядку в списке — при пересечении имён полей побеждает то, что указано позже).

Проверить итоговый результат до того, как индекс реально создан, можно через _index_template/_simulate_index — API, который прогоняет резолвинг шаблонов для гипотетического имени индекса и показывает, что получится, без побочных эффектов. Вот реальный ответ стенда 3.5.0 для app-logs-2026.07:

{
  "template" : {
    "settings" : {
      "index" : {
        "number_of_shards" : "1",
        "number_of_replicas" : "0"
      }
    },
    "mappings" : {
      "dynamic" : "strict",
      "properties" : {
        "latency_ms" : {
          "type" : "float"
        },
        "level" : {
          "type" : "keyword"
        },
        "message" : {
          "type" : "text",
          "fields" : {
            "keyword" : {
              "type" : "keyword",
              "ignore_above" : 512
            }
          }
        },
        "service" : {
          "type" : "keyword"
        },
        "status" : {
          "type" : "short"
        },
        "ts" : {
          "type" : "date"
        }
      }
    },
    "aliases" : { }
  },
  "overlapping" : [ ]
}

Сравните это с тем, что dynamic mapping вывел сам в разделе «Первый документ и dynamic mapping»: там leveltext с .keyword-под-полем и ignore_above: 256, здесь — чистый keyword, потому что level на практике не текст для полнотекстового поиска, а фиксированный набор значений. Там statuslong, здесь — short, потому что реальный диапазон HTTP-кодов туда укладывается. И главное — "dynamic" : "strict" в корне mappings: это унаследовано из index template, а не из component template, и означает, что любое поле за пределами перечисленных выше шести отклонит запись целиком (см. «Контроль над схемой»). Поле overlapping: [] в ответе — список других index templates, чьи index_patterns тоже подошли бы под app-logs-2026.07; пустой список значит, что конфликтов приоритетов нет.

Полный набор файлов — component-templates.json, index-template.json, демо-документы и скрипт, который поднимает стенд и прогоняет весь цикл целиком, — лежит в digital-cookbook, opensearch/indices-mappings/.

Алиасы и ротация

Индекс app-logs-2026.07 в имени содержит дату — это осознанное решение под ротацию, но оно создаёт проблему: приложение, которое пишет логи, не должно каждый месяц менять имя индекса в своей конфигурации. Здесь в игру вступает алиас — дополнительное имя, которое указывает на один или несколько реальных индексов, и именно по алиасу, а не по конкретному имени с датой, приложение читает и пишет. Приложение всегда обращается к app-logs; какой физический индекс (app-logs-2026.08, app-logs-2026.09, …) стоит за этим именем сегодня — забота инфраструктуры, а не кода.

При чтении алиас может указывать сразу на несколько индексов одновременно — запрос к app-logs прозрачно уходит во все индексы семейства, и результаты собираются вместе, как если бы это был один индекс. При записи так нельзя: OpenSearch должен точно знать, в какой физический индекс класть новый документ, поэтому среди всех индексов алиаса ровно один может быть помечен is_write_index: true — только он принимает записи через этот алиас, пока остальные обслуживают только чтение.

Вот как это выглядит на стенде 3.5.0: алиас app-logs указывает на app-logs-2026.08 с is_write_index: true, и запись через алиас реально уходит в этот индекс:

пишу через алиас — уходит в write-индекс:
    -> app-logs-2026.08

Ротация по времени (новый индекс раз в месяц) или по размеру (новый индекс, когда текущий превысил порог по данным или числу документов) — операция ровно над этим одним указателем is_write_index, а не над кодом приложения и не над данными. Создаётся новый индекс app-logs-2026.09 (он тоже автоматически получает нужную схему — попадает под тот же index template app-logs-* из предыдущего раздела), и одним атомарным запросом к _aliases перевешивается флаг записи:

{
  "actions": [
    { "add": { "index": "app-logs-2026.08", "alias": "app-logs", "is_write_index": false } },
    { "add": { "index": "app-logs-2026.09", "alias": "app-logs", "is_write_index": true } }
  ]
}

Ключевое слово здесь — «одним запросом»: _aliases принимает список actions и применяет их как единую транзакцию. Снятие флага с app-logs-2026.08 и установка флага на app-logs-2026.09 происходят атомарно — не бывает промежуточного состояния, в котором у алиаса app-logs нет ни одного write-индекса (запись в этот момент отклонилась бы) или есть два одновременно (OpenSearch и такое не допустит). Приложение как писало в app-logs, так и продолжает писать в app-logs без единой строчки изменений в коде и без окна простоя — просто с какого-то момента документы физически оказываются в новом индексе.

Вот реальное состояние алиаса на стенде 3.5.0 после свапа — уже с тремя индексами в семействе, _cat/aliases показывает ровно одно true:

alias    index            is_write_index
app-logs app-logs-2026.07 -
app-logs app-logs-2026.08 false
app-logs app-logs-2026.09 true

Индекс app-logs-2026.07 вовсе не значится в is_write_index (прочерк, а не false) — он присоединился к алиасу позже как read-only, без явного указания флага при add. app-logs-2026.08, вчерашний write-индекс, теперь false: доступен на чтение, но больше не принимает новые документы. Чтение по алиасу app-logs при этом видит все три индекса сразу — старые данные никуда не делись, изменился только адрес для новых записей.

Мост к ISM и retention

Ротация из предыдущего раздела решает половину задачи: новые данные попадают в свежий индекс без изменений в коде приложения. Вторая половина — старые индексы рано или поздно нужно удалять, иначе app-logs-2026.07, app-logs-2026.08 и все последующие будут копиться в кластере бесконечно. Делать это руками (следить за датами, вручную удалять индексы по расписанию) — задача, которая идеально ложится на автоматизацию, и именно для неё в OpenSearch есть ISM (Index State Management).

ISM работает поверх ровно того же механизма, что уже встретился в статье: индексы с предсказуемыми именами, подобранные по маске index_patterns — только вместо шаблона маппинга к ним привязывается политика состояний. Индекс проходит через состояния (например, warmdelete), и переход между ними триггерится условием — чаще всего возрастом индекса. Вот санитизированная, упрощённая версия политики со стенда — тот же принцип, что применяется к семейству app-logs-*:

{
  "policy": {
    "policy_id": "auto_clean_app-logs",
    "description": "app-logs: удаление через 14 дней",
    "default_state": "warm",
    "states": [
      { "name": "warm", "actions": [], "transitions": [
        { "state_name": "delete", "conditions": { "min_index_age": "14d" } } ] },
      { "name": "delete", "actions": [
        { "retry": { "count": 3, "backoff": "exponential", "delay": "1m" }, "delete": {} } ],
        "transitions": [] }
    ],
    "ism_template": [ { "index_patterns": ["app-logs-*"], "priority": 1 } ]
  }
}

Логика читается так же, как index template из раздела «Index templates и component templates»: ism_template.index_patterns цепляет политику к любому индексу семейства app-logs-* в момент его создания — не нужно вручную привязывать политику к каждому новому app-logs-2026.09. Индекс стартует в состоянии warm и ничего не делает, пока не выполнится условие перехода: как только min_index_age достигает 14 дней, ISM переводит индекс в состояние delete, а там действие delete удаляет индекс целиком (с повторными попытками при сбое — блок retry).

Это — только мост. Настоящий retention устроен богаче: помимо delete есть переходы в warm/cold для более дешёвого хранения старых данных, rollover по размеру или числу документов вместо (или вместе с) ротацией по времени, снапшоты перед удалением. Всё это — тема отдельной статьи серии, а не пары абзацев здесь.

Типичные ошибки

  • Mapping explosion. dynamic: true на индексе, куда пишутся документы с непредсказуемыми ключами (произвольные HTTP-заголовки, атрибуты события в корне документа), — маппинг разрастается до тысяч полей, и кластер начинает тратить память на метаданные каждого из них. Лечится ограничением схемы: явные properties для известных полей плюс dynamic_templates под режимом dynamic: strict_allow_templates (непредвиденное поле отклоняется) или false_allow_templates (непредвиденное поле остаётся в _source, но не индексируется). Обычные strict/false тут не подходят: они не делают исключения для полей, совпавших с dynamic_templates, — см. «Контроль над схемой».
  • Тип поля нельзя изменить у существующего индекса. Ни textkeyword, ни longshort не переопределяются задним числом — единственный путь — создать новый индекс с нужным маппингом и переиндексировать данные через _reindex. Отсюда правило статьи: продумывать схему до первого документа, а не после.
  • text без .keyword — нельзя агрегировать и сортировать. Попытка terms-агрегации или сортировки прямо по text-полю вернёт Fielddata is disabled on text fields by default, а не просто отработает медленнее. Нужен соседний keyword-вариант (multi-field) — см. «Анализаторы».
  • Слишком много шардов на мелких индексах. Каждый шард — это отдельный экземпляр Lucene со своими накладными расходами (открытые файловые дескрипторы, память под метаданные сегментов); десятки мелких индексов с несколькими primary-шардами каждый создают overhead, который не окупается объёмом данных. Для большинства логовых индексов среднего размера достаточно одного primary-шарда — как в app-logs из раздела «Index templates».
  • Забытый is_write_index при ротации. Если при создании нового индекса семейства не снять флаг со старого и не выставить его на новом одним атомарным запросом к _aliases (см. «Алиасы и ротация»), у алиаса может не оказаться ни одного write-индекса — запись отклонится, — либо, при ручных неаккуратных действиях, конфликт при попытке выставить флаг сразу двум индексам.

Что дальше

Схема готова, индексы создаются по шаблону, ротация переключает запись атомарно — но без данных внутри всё это бесполезно. В следующей статье серии — сбор данных: клиенты и bulk: как писать в OpenSearch эффективно, а не по документу за раз.

Ещё в очереди — retention и ISM целиком: rollover, состояния warm/cold, снапшоты и удаление по расписанию поверх моста, показанного выше. И отдельно — OpenSearch Dashboards: как смотреть на те же индексы и данные глазами, а не только через API.

Официальные источники

  • Mapping — обзор маппинга и типов полей
  • Supported field types — полный список типов и их параметров
  • Text analysis — анализаторы, tokenizer, character и token filter
  • Index templates — index templates и component templates
  • Index aliases — алиасы, is_write_index, работа с несколькими индексами
  • Index State Management — обзор ISM: состояния, действия, переходы, политики

Обсуждение в Telegram

Присоединиться →

Комментарии