OpenSearch Dashboards: Discover, визуализации и дашборды

Как работать с данными OpenSearch через Dashboards: index patterns, поиск в Discover, визуализации и дашборды, saved objects и доступ по ролям поверх security plugin

Данные собраны и проиндексированы — но смотреть на них через curl неудобно. OpenSearch Dashboards — веб-интерфейс поверх кластера: поиск и фильтрация в Discover, визуализации, дашборды, инструменты администрирования. В предыдущих статьях серии выделенный хост os-dashboard уже фигурировал в архитектуре кластера; здесь — как им реально пользоваться.

OpenSearch Dashboards

О версии. Примеры в статье проверены на связке OpenSearch 3.5.0 + OpenSearch Dashboards 3.5.0 — том же стенде, что и в предыдущих статьях серии. Security plugin включён (демо-режим), поэтому весь доступ к Dashboards идёт через логин поверх ролевой модели из статьи про кластер. Воспроизводимый стенд (OpenSearch + Dashboards с security, saved objects и RBAC) лежит в digital-cookbook, opensearch/dashboards/.

Если коротко — вот к чему мы идём: приборная панель, собранная из данных кластера, которую можно читать с одного экрана вместо десятков curl-запросов.

Приборная панель Dashboards: графики и индикаторы, собранные из данных кластера

В статье

flowchart LR U["Браузер"] -->|"HTTPS, логин"| D["OpenSearch Dashboards"] D -->|"REST, сервисный пользователь kibanaserver"| O["OpenSearch кластер"] D -->|"saved objects: index patterns,
visualizations, dashboards"| K[(".opensearch_dashboards")] O --- K

flowchart LR
    U["Браузер"] -->|"HTTPS, логин"| D["OpenSearch Dashboards"]
    D -->|"REST, сервисный пользователь kibanaserver"| O["OpenSearch кластер"]
    D -->|"saved objects: index patterns,
visualizations, dashboards"| K[(".opensearch_dashboards")] O --- K
Dashboards поверх кластера: browser → Dashboards → OpenSearch, saved objects в .opensearch_dashboards

Dashboards — не самостоятельное хранилище: сам процесс не держит данные, а ходит в кластер по REST от имени сервисного пользователя и держит собственную конфигурацию (index patterns, визуализации, дашборды) в служебном индексе .opensearch_dashboards того же кластера. Имя этого индекса — по умолчанию .opensearch_dashboards в OpenSearch Dashboards 3.x (задаётся настройкой opensearchDashboards.index); на старых или переопределённых стендах может встречаться прежнее .kibana — суть та же. Это значит, что перенос Dashboards на другой инстанс не требует ничего, кроме доступа к тому же кластеру и тому же служебному индексу — сами данные визуализаций живут не в браузере и не на диске Dashboards, а в OpenSearch.

Подключение и вход

Dashboards подключается к кластеру через конфигурационный файл opensearch_dashboards.yml — там же, где адрес кластера, TLS и сервисный пользователь, от имени которого Dashboards ходит в OpenSearch. Вот значимые строки с реального стенда (значения хоста и пароля санитизированы):

opensearch.hosts: [https://<cluster-host>:9200]      # на стенде переопределяется env OPENSEARCH_HOSTS
opensearch.ssl.verificationMode: none                # demo self-signed TLS (в проде — full + CA)
opensearch.username: kibanaserver                    # сервисный пользователь
opensearch.password: <service-password>              # DEMO-ONLY; в проде — секрет
opensearch.requestHeadersWhitelist: [authorization, securitytenant]
opensearch_security.multitenancy.enabled: true
opensearch_security.multitenancy.tenants.preferred: [Private, Global]
opensearch_security.readonly_mode.roles: [kibana_read_only]
opensearch_security.cookie.secure: false             # DEMO-ONLY; в проде true (HTTPS)
server.host: '0.0.0.0'

opensearch.username/opensearch.password — не учётная запись конкретного человека, а служебный пользователь kibanaserver, от имени которого сам процесс Dashboards читает и пишет в кластер (в том числе свою собственную конфигурацию в .opensearch_dashboards). opensearch.ssl.verificationMode: none отключает проверку сертификата кластера — годится только для самоподписанного demo-стенда, в проде — full с доверенным CA, аналогично TLS-оговоркам в предыдущих статьях серии.

Две строки отвечают за то, что происходит при входе конкретного человека в интерфейс. opensearch.requestHeadersWhitelist — список HTTP-заголовков, которые Dashboards разрешает прокидывать дальше в OpenSearch поверх сервисного соединения (в свежих версиях эта настройка называется opensearch.requestHeadersAllowlist; Whitelist — устаревший синоним, он и попал во врезку как есть со стенда, но по-прежнему работает); authorization здесь — это то, как в кластер долетают учётные данные вошедшего пользователя (а не только сервисного kibanaserver), а securitytenant — заголовок, которым интерфейс сообщает кластеру, какой tenant сейчас выбран. Именно тут в игру входит opensearch_security.multitenancy.enabled: true: он включает мультитенантность целиком, а multitenancy.tenants.preferred: [Private, Global] задаёт, какие tenant’ы предлагаются пользователю — Private (личное пространство, saved objects видны только владельцу) и Global (общее пространство, видно всем с доступом). Сам вход при этом остаётся логином по учётной записи, заведённой в security plugin ещё в статье про кластер — Dashboards не вводит отдельную систему пользователей, а только добавляет поверх нее выбор tenant’а и (через readonly_mode.roles) роль kibana_read_only, которая делает интерфейс доступным для просмотра без права что-либо менять.

Экран входа OpenSearch Dashboards

На экране входа — стандартная форма security plugin: логин и пароль той же учётной записи, что и для REST-запросов к кластеру напрямую. После успешного входа Dashboards, если включена мультитенантность, может дополнительно предложить выбрать tenant (Private/Global) — от него зависит, какие saved objects будут видны в этой сессии.

Index patterns

Прежде чем что-либо искать или визуализировать, Dashboards должен знать, по какому набору индексов работать и какое поле в документах считать временной осью. За это отвечает index pattern — saved object, который связывает имя (или маску) индекса с полем времени, но не хранит и не копирует сами данные: это просто ссылка на то, как читать существующие индексы кластера.

На стенде данные лежат в индексе app-logs-000001 за алиасом app-logs, поэтому паттерн задан по семейству через маску app-logs-* — это позволяет Dashboards видеть не только текущий индекс, но и все последующие после ролловера (ротация и алиасы разбирались в статье про индексы и маппинги). Вот реальный index pattern со стенда — как он выглядит через saved-objects API:

{
  "id": "app-logs",
  "type": "index-pattern",
  "attributes": { "title": "app-logs-*", "timeFieldName": "@timestamp" }
}

title — это и есть маска индексов (app-logs-*, а не имя одного конкретного индекса), timeFieldName — поле, которое Dashboards использует как временную ось везде: в диапазоне времени над Discover, в тайм-бакетах визуализаций, в сортировке по умолчанию. Dashboards определяет тип этого поля по маппингу индекса — на стенде @timestamp объявлен как date (см. явный маппинг типов полей), и это единственный тип, с которым временная ось работает предсказуемо.

Индекс app-logs-000001 в Index Management — под него и подводится index pattern app-logs-*

На скрине — раздел Index Management со списком индексов: сам app-logs-000001 (300 документов, статус green), тот физический индекс, который и попадает под маску app-logs-*. Index pattern — это ссылка на такие индексы: пока индекс существует и подходит под маску, паттерн его видит; появится app-logs-000002 после ролловера — паттерн подхватит и его без единой правки в интерфейсе.

Создание index pattern через интерфейс — тот же результат, что и вызов saved-objects API выше: мастер запрашивает маску индексов, показывает, какие реальные индексы кластера под неё подпадают, и — если среди полей маппинга есть поле типа date — просит выбрать time field. Пропустить этот шаг можно (index pattern без time field создаётся, если данные не временные), но для логов и метрик отсутствие time field на самом деле означает потерю диапазона времени и тайм-бакетов почти везде в интерфейсе.

Index pattern не фиксирует список полей раз и навсегда: при изменении маппинга индекса (новое поле, смена типа) Dashboards подхватывает изменения либо автоматически при следующем обращении, либо по явному действию «Refresh field list» в настройках index pattern — это отдельная операция, отличная от изменения самого title/timeFieldName, и про неё стоит помнить при развитии схемы: добавили поле в маппинг — обновите список полей паттерна, иначе новое поле не появится в Discover и в конструкторе визуализаций.

Discover

Discover — экран для поиска и просмотра сырых документов по index pattern: строка запроса, гистограмма распределения по времени над таблицей, список полей слева, сама таблица с документами. Это первое, куда стоит заходить при разборе инцидента — до того, как строить визуализацию, полезно увидеть, что вообще лежит в индексе.

Строка поиска работает на DQL (DQL Query Language) — языке, спроектированном так, чтобы формулировать фильтры по полям без синтаксиса query DSL: level: ERROR and service: api читается почти как обычная фраза. Помимо строки запроса, к результату можно добавлять фильтры-пилюли (+ Add filter) — они не хранятся внутри текста запроса, а показываются отдельными «таблетками» под строкой поиска и комбинируются с ней через AND. Разница практическая: DQL-строку удобно быстро переписать целиком, а фильтр-пилюлю — включить, выключить или инвертировать в один клик, не трогая остальной запрос.

Список полей слева — не просто справочник: клик по полю добавляет его колонкой в таблицу, а порядок колонок задаёт, что видно в первую очередь. Сортировка по умолчанию идёт по time field из index pattern (свежие документы сверху), но её можно переключить на любую другую колонку. Диапазон времени в правом верхнем углу — тот же time field @timestamp, что задан в index pattern app-logs-*; изменение диапазона перестраивает и таблицу, и гистограмму над ней, не трогая сам текст запроса.

Найденный вид — запрос, набор колонок, диапазон времени и фильтры — можно сохранить как saved object («Save» в верхней панели) и переиспользовать позже или как источник для визуализации.

Ключевая механика Discover: DQL — это удобный слой поверх обычного query DSL, который Dashboards и отправляет в кластер по REST. На стенде это видно напрямую — DQL-строка и получившийся _search-запрос дают идентичный результат:

{
  "bool": {
    "filter": [
      { "term": { "level": "ERROR" } },
      { "term": { "service": "api" } }
    ]
  }
}

Этот запрос вернул total: 11 — 11 документов, где level строго ERROR и service строго api. Обратите внимание на форму: DQL с and между двумя полями превращается в bool.filter с двумя term-условиями — точное совпадение по structured-полям, без начисления релевантности (в filter, а не в must, как и разбиралось при работе с фильтрами в статье про полнотекстовый поиск).

Второй пример — поиск по свободному тексту вместо точных полей:

{ "match": { "message": "timeout" } }

DQL message: timeout превращается уже не в term, а в match — полнотекстовый запрос по анализируемому полю message. Результат — total: 9. Разница с первым примером показательна: как только DQL-выражение затрагивает текстовое поле, Dashboards выбирает match вместо term, потому что message проанализировано (токенизировано), и точное совпадение строки тут не имеет смысла. Один из документов, попавших в эту выборку:

{
  "@timestamp": "2026-07-28T09:04:56Z",
  "level": "ERROR",
  "service": "api",
  "message": "request timeout"
}

Этот же документ подходит и под первый DQL-запрос (level: ERROR and service: api), и под второй (message: timeout) — совпадение неслучайно: в демо-наборе часть ERROR-событий сервиса api описывают именно таймауты запросов.

Discover: поиск по DQL с гистограммой

На скрине — типичный вид Discover: строка DQL сверху с введённым запросом, под ней гистограмма количества документов по временным интервалам (высота столбца — число событий в интервале), ниже — таблица с колонками @timestamp, level, service, message. Гистограмма кликабельна: выделение диапазона на ней сужает и диапазон времени, и таблицу — быстрый способ провалиться в конкретный всплеск ошибок, не трогая текст запроса.

Одна важная деталь про числа. Discover всегда работает внутри выбранного диапазона времени (он справа сверху), а не по всему индексу: на скрине счётчик показывает 9 совпадений, тогда как тот же запрос выше через _search вернул total: 11. Это не противоречие — просто два события с level: ERROR и service: api лежат за границей выбранного окна. То же и с временными зонами: @timestamp в документах — в UTC, а окно времени интерфейс показывает в часовом поясе браузера, так что «сутки» на экране могут не совпадать с сутками в данных. Полное, независимое от окна число всегда можно получить в Dev Tools (см. ниже), где диапазона времени нет вовсе.

Визуализации

Визуализация в Dashboards — это сохранённый способ агрегировать и показать данные из index pattern: столбчатая диаграмма (bar), линейный график (line), круговая диаграмма (pie), одно число (metric), таблица данных (data table) и другие типы. Конструктор визуализации не придумывает новый способ считать — под каждым типом лежит обычный _search-запрос с агрегацией: те же buckets и metrics, что разбирались в статье про агрегации. Bucket-агрегация (например, terms по полю) определяет, на какие группы бьются документы и что будет по одной оси диаграммы; metric-агрегация (count, avg, sum и так далее) определяет, что показывается по другой оси — обычно это высота столбца, длина сектора или число в metric-визуализации.

Показательный пример — столбчатая визуализация «Events by level» из saved objects стенда. За ней стоит обычная terms-агрегация по полю level, ту же самую можно выполнить руками в Dev Tools:

{
  "size": 0,
  "aggs": {
    "by_level": {
      "terms": { "field": "level" }
    }
  }
}

Ответ кластера — три bucket’а по значениям поля level, каждый со своим doc_count:

{
  "by_level": {
    "buckets": [
      { "key": "INFO", "doc_count": 192 },
      { "key": "WARN", "doc_count": 66 },
      { "key": "ERROR", "doc_count": 42 }
    ]
  }
}

Визуализация просто рисует эти bucket’ы как столбцы, от большего к меньшему: INFO, WARN, ERROR. Никакой отдельной логики подсчёта в самой визуализации нет — конструктор в интерфейсе лишь собирает JSON-агрегацию по выбранным полям и типу диаграммы, отправляет её тем же REST-запросом, что и curl или Dev Tools, и рендерит buckets в SVG.

Столбчатая визуализация событий по level

Как и Discover, визуализация считает не по всему индексу, а по выбранному диапазону времени, поэтому высоты столбцов на скрине меньше полных 192 / 66 / 42 из агрегации выше — часть событий осталась за границей окна. Полное, независимое от диапазона число даёт Dev Tools, где времени нет; именно в этом и разница между «числом в отчёте» и «числом на дашборде», к которой мы ещё вернёмся в типичных ошибках.

На скрине — три столбца по значениям level: заметно более высокий INFO, ниже него WARN, ещё ниже ERROR — пропорционально doc_count из агрегации выше. Подпись оси X — значения terms-агрегации (INFO/WARN/ERROR), ось Y — количество документов.

Именно потому, что визуализация — это просто отрисованная агрегация, она способна вводить в заблуждение теми же способами, что и агрегация сама по себе, плюс несколько специфичных для отображения:

  • Усечённые bucket’ы. У terms-агрегации есть параметр size — сколько top-bucket’ов вернуть; по умолчанию конструктор визуализации берёт небольшое число (пять или десять). Если уникальных значений поля больше — часть данных молча уходит в отсечение, а диаграмма выглядит полной и не сигнализирует об этом.
  • Неверная метрика. count документов и, например, sum числового поля — принципиально разные вещи; столбец «выше» не обязательно значит «событий больше», если метрика — это сумма или среднее, а не количество. Подпись оси и легенда — единственная защита от неправильного прочтения.
  • Отсутствие или скрытый диапазон времени. Визуализация, как и Discover, обычно завязана на диапазон времени из верхней панели. Если диапазон стоит «last 15 minutes» по умолчанию, а разбирается инцидент недельной давности — диаграмма покажет пустоту или обрезанную картину, не сообщив явно, что смотрит не в тот интервал.
  • Неподписанная ось Y на bar/line. Без явных подписей значений на столбцах разница между, скажем, 190 и 210 событиями визуально может казаться драматичнее, чем есть — особенно если ось не начинается с нуля.

Прежде чем доверять диаграмме, стоит на секунду свериться с тем, что реально лежит в основе: какая агрегация, какой size, какая метрика и какой диапазон времени — то же самое, что curl-запрос в Dev Tools возвращает без визуального слоя.

Дашборды

Дашборд — следующий уровень над отдельной визуализацией: не одна диаграмма, а несколько панелей, собранных на одном экране. Панелью может быть визуализация (bar, line, pie, metric, data table — всё, что разбиралось выше) или сохранённый поиск из Discover — таблица документов с теми же колонками и сортировкой, что были на момент сохранения. Дашборд не пересчитывает и не копирует то, что рисуют панели: он просто размещает уже существующие saved objects на сетке и добавляет то, что относится ко всему экрану сразу, а не к одной панели.

Именно это — управление на уровне всего дашборда — и есть основная причина собирать панели вместе, а не смотреть на визуализации по отдельности:

  • Диапазон времени задаётся один раз в верхней панели дашборда и применяется ко всем панелям одновременно — если одна панель показывает bar-диаграмму по level, а другая — data table сырых событий, обе пересчитаются на один и тот же интервал при его изменении.
  • Фильтры и строка запроса дашборда работают так же, как в Discover: DQL-строка и фильтры-пилюли, добавленные на уровне дашборда, накладываются на все панели через AND, не трогая индивидуальные настройки, сохранённые внутри отдельной визуализации.
  • Drilldown — переход с панели на другой дашборд или на Discover с подстановкой контекста клика (например, клика по конкретному bucket’у terms-агрегации как фильтра). Настраивается на уровне панели или всего дашборда; в базовом сценарии — просто ссылка с сохранением текущих фильтров и диапазона времени, без отдельной инфраструктуры.

На стенде дашборд app-logs-overview собран из одной панели — визуализации «Events by level», разобранной в предыдущем разделе. Логика та же и для дашборда из десяти панелей: разница только в количестве, не в механике.

Собранный дашборд из нескольких панелей

На скрине — дашборд с панелью «Events by level» на сетке, диапазоном времени и строкой запроса в верхней панели, общими для всего экрана. Как и визуализация, и index pattern, дашборд — это тоже saved object: то, что видно на экране, целиком описывается связкой panelsJSON (какие панели, где на сетке, с какими размерами) и references (какие именно saved objects — id визуализаций и сохранённых поисков — стоят за каждой панелью). Эта связка по id, а не по названию или содержимому, и есть мост к следующему разделу — она же лежит в основе экспорта и переноса конфигурации между окружениями.

Saved objects

Index pattern, визуализация, дашборд, сохранённый поиск из Discover — всё перечисленное в предыдущих разделах представлено одним и тем же механизмом: saved object, документ в служебном индексе .opensearch_dashboards того же кластера OpenSearch. Dashboards не хранит свою конфигурацию отдельно от данных — index patterns, визуализации, дашборды, сохранённые поиски физически лежат там же, где и индексы с логами, только в другом, служебном индексе. Именно поэтому в начале статьи говорилось, что перенос Dashboards на другой инстанс не требует ничего, кроме доступа к тому же .opensearch_dashboards: сама конфигурация интерфейса — не файлы на диске Dashboards, а обычные документы OpenSearch.

Saved-objects API даёт прямой доступ к этому механизму — тем же способом, каким в начале статьи создавался index pattern app-logs через POST /api/saved_objects/index-pattern/app-logs. Экспорт работает симметрично: POST /api/saved_objects/_export с указанием типов и includeReferencesDeep: true выгружает объекты вместе со всеми зависимостями, на которые они ссылаются. На стенде запрос {"type":["index-pattern","visualization","dashboard"],"includeReferencesDeep":true} вернул три объекта — ровно цепочку зависимостей дашборда из предыдущего раздела, а не произвольный список:

{"type":"index-pattern","id":"app-logs","attributes":{"title":"app-logs-*","timeFieldName":"@timestamp"},"references":[]}
{"type":"visualization","id":"events-by-level","attributes":{"title":"Events by level",...},"references":[{"name":"kibanaSavedObjectMeta.searchSourceJSON.index","type":"index-pattern","id":"app-logs"}]}
{"type":"dashboard","id":"app-logs-overview","attributes":{"title":"App logs overview",...},"references":[{"id":"events-by-level","name":"panel_1","type":"visualization"}]}
{"exportedCount":3,"missingRefCount":0,"missingReferences":[]}

Формат — NDJSON: по одному JSON-объекту на строку, последняя строка — сводка (exportedCount, missingRefCount, missingReferences). Цепочка зависимостей видна прямо в references каждого объекта: dashboard app-logs-overview ссылается на visualization events-by-level через panel_1 (та самая связка panelsJSON/references, о которой шла речь в разделе про дашборды), а visualization в свою очередь ссылается на index-pattern app-logs. includeReferencesDeep: true и означает — не выгружать один запрошенный тип, а протянуть всю цепочку до конца: попросили dashboard, получили ещё visualization и index-pattern, без которых дашборд не откроется на другом инстансе. missingRefCount: 0 в сводке подтверждает, что ни одна ссылка не повисла в пустоте — все id, упомянутые в references, реально попали в экспорт.

Принципиальный момент — на что именно ссылаются объекты. references связывают saved objects между собой по id, а не по имени индекса или содержимому напрямую: visualization знает не «индекс app-logs-*», а «id app-logs, тип index-pattern», и лишь через этот id доходит до реального имени индекса, прописанного в атрибутах index pattern. Это и делает перенос между окружениями предсказуемым: импорт того же NDJSON на другом инстансе (dev → prod, второй кластер, восстановление после потери .opensearch_dashboards) воссоздаёт всю цепочку целиком через POST /api/saved_objects/_import, при условии, что id объектов не меняются между окружениями. Ссылка по id не означает, что окружения обязаны быть идентичны во всём — индекс с именем app-logs-* и его данные должны существовать в целевом кластере отдельно, saved objects переносят только конфигурацию интерфейса, а не сами документы.

Практическое следствие: раз конфигурация Dashboards — это NDJSON, а не состояние в браузере или несериализуемая настройка на диске, её можно и стоит версионировать в Git тем же способом, что и остальной код инфраструктуры — экспортировать перед изменением, коммитить рядом с ansible-плейбуками и security-конфигурацией из статьи про кластер, и накатывать импортом при разворачивании нового окружения вместо ручного повторения кликов в интерфейсе.

Dev Tools

Все REST-запросы, которые встречались в статье до этого раздела — агрегация под визуализацией «Events by level», DQL-примеры из Discover, вызовы saved-objects API — отправлялись в кластер одним и тем же способом, каким их отправил бы curl напрямую. Dev Tools — встроенная в Dashboards консоль, которая делает это явным: текстовый редактор с REST-запросами внутри интерфейса, без переключения в терминал.

Три вещи отличают Dev Tools от голого curl: автодополнение по именам индексов, полям и синтаксису query DSL прямо во время набора запроса; история выполненных запросов, к которой можно вернуться, не набирая их заново; и построчный формат МЕТОД путь с телом запроса ниже, который выполняется без ручной сборки заголовков и экранирования JSON. Аутентификация та же, что и у остального интерфейса, — учётная запись, под которой выполнен вход.

Практическая польза Dev Tools — не в том, что она заменяет curl синтаксисом, а в том, где она встаёт в рабочий процесс: агрегацию или фильтр удобнее один раз собрать и проверить построчно в консоли, глядя на реальный ответ кластера, и только потом переносить в конструктор визуализации или в код. Вот тот же запрос, что уже приводился в разделе «Визуализации» — терм-агрегация по level, лежащая в основе диаграммы «Events by level», выполненная через Dev Tools:

POST app-logs/_search
{
  "size": 0,
  "aggs": {
    "by_level": {
      "terms": { "field": "level" }
    }
  }
}

Ответ кластера — те же три bucket’а, что и при выполнении этого запроса любым другим способом:

{ "by_level": { "buckets": [
  {"key":"INFO","doc_count":192},
  {"key":"WARN","doc_count":66},
  {"key":"ERROR","doc_count":42}
] } }

Совпадение результата не случайность и не совпадение чисел — это один и тот же REST-вызов POST app-logs/_search с одним и тем же телом, вне зависимости от того, откуда он отправлен: из Dev Tools, из curl или из конструктора визуализации за кулисами. Dev Tools ничего не меняет в том, что кластер получает и что возвращает, — она только убирает накладные расходы на сборку HTTP-запроса руками при отладке.

Dev Tools console

На скрине — панель Dev Tools: слева редактор запроса с построчным POST app-logs/_search и телом агрегации ниже, справа — ответ кластера в том виде, в каком он пришёл по REST. Это и есть основной сценарий использования: собрать запрос, проверить результат по факту, а не по ожиданию, прежде чем закладывать его в визуализацию или в код.

Доступ по ролям

Вход в Dashboards — это логин по учётной записи security plugin, а не отдельная система пользователей: сама модель ролей и разрешений была заведена ещё в статье про кластер, и Dashboards поверх неё не добавляет ничего нового — только интерфейс, который ходит в кластер с теми же правами, что и REST-запрос под тем же пользователем. Роль, ограниченная конкретным набором индексов, работает одинаково и через curl, и через Discover, и через Dev Tools.

На стенде для этого заведена роль app_logs_reader, ограниченная index-паттерном app-logs-*. Вот она, как есть, через API управления ролями:

{
  "cluster_permissions": ["cluster_composite_ops_ro"],
  "index_permissions": [
    {
      "index_patterns": ["app-logs-*"],
      "allowed_actions": ["read","search","indices:data/read/*","indices_monitor"]
    }
  ]
}

cluster_permissions даёт только read-only операции на уровне кластера (cluster_composite_ops_ro) — ни создать индекс, ни поменять настройки. index_permissions сужает это ещё дальше: index_patterns: ["app-logs-*"] означает, что перечисленные ниже allowed_actions (read, search, indices:data/read/*, indices_monitor) действуют только на индексы, подпадающие под эту маску, — и ни на какие другие. Роль не запрещает остальные индексы явным списком, она просто не выдаёт по ним никаких прав, а security plugin по умолчанию — deny.

Роль сама по себе не даёт доступа — её нужно привязать к пользователю через role_mapping, и отдельно открыть вход в интерфейс:

PUT _plugins/_security/api/rolesmapping/app_logs_reader
{ "users": ["reader"] }

PUT _plugins/_security/api/rolesmapping/kibana_user
{ "users": ["reader"] }

Первая привязка — собственно доступ к данным app-logs-* через роль app_logs_reader. Вторая — отдельная и обязательная: без роли kibana_user пользователь может иметь сколько угодно прав на индексы через REST, но не сможет зайти в сам интерфейс Dashboards. Это две разные вещи — доступ к данным и доступ к интерфейсу — и обе настраиваются отдельными role_mapping.

Доказательство изоляции — не то, что написано в определении роли, а то, что реально отвечает кластер под этим пользователем. На стенде под учётной записью reader выполнены три запроса:

POST app-logs/_search
→ total: 300

Свои данные reader читает полностью — все 300 документов индекса app-logs, ровно как и ожидается от роли, разрешающей read/search на app-logs-*. Тот же пользователь, тот же кластер, но чужой индекс:

POST other-000001/_search
→ HTTP 403, type: security_exception

Индекс other-000001 не подпадает под app-logs-* — и ответ не пустой результат, а прямой отказ на уровне security plugin. Третий запрос показывает, что изоляция работает не только на чтение документов, но и на саму видимость индексов:

GET _cat/indices/app-logs-*,other-*
→ 403 security_exception:
no permissions for [indices:monitor/settings/get] and User [name=reader, backend_roles=[], requestedTenant=null]

reader не просто не может прочитать other-000001 — он не может даже перечислить его через _cat/indices, потому что app_logs_reader не выдаёт indices:monitor/settings/get ни на что, кроме app-logs-*. Разница принципиальная: для reader индексы вне его паттерна не «пустые» или «запрещённые к чтению» — они как будто не существуют в кластере вовсе, запрос на их перечисление отклоняется тем же кодом, что и запрос на чтение данных.

В тексте ошибки есть ещё одна деталь — requestedTenant=null. Это контекст мультитенантности, о которой шла речь в разделе «Подключение и вход»: Dashboards сообщает кластеру выбранный tenant (Private или Global) через заголовок securitytenant, и security plugin включает это значение в контекст каждого запроса, в том числе в сообщения об ошибках. null здесь означает, что запрос выполнен вне интерфейса Dashboards — напрямую к REST, без заголовка securitytenant — и потому tenant не задан вовсе. Внутри самого интерфейса тот же reader, залогинившись, увидит выбор между Private (saved objects, видимые только ему) и Global (общее пространство) — но на доступ к данным app-logs-*/other-* выбор tenant не влияет: это два независимых измерения security plugin — какие индексы можно читать по REST и какие saved objects видны в интерфейсе.

Reader создаёт index pattern: маска app* находит app-logs (alias) и app-logs-000001

Reader создаёт index pattern: маска other* не находит ни одного индекса

Та же изоляция видна прямо в интерфейсе, если зайти под reader и попробовать завести index pattern. Маска app* находит два источника — алиас app-logs и индекс app-logs-000001 (первый скрин): к своим данным доступ есть. Маска other* не находит ничего — «matches 0 indices» (второй скрин): индекс other-000001 существует в кластере, но для reader его как будто нет, ровно так, как и предсказывает 403 на _cat/indices из примера выше. Модель ролей — та же, что описана в статье про кластер; Dashboards не создаёт для неё исключений, а лишь наглядно показывает, что каждый REST-вызов интерфейса продолжает подчиняться тем же правилам.

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

  • Index pattern без time field на временных данных. Мастер создания index pattern не требует указывать time field — можно пропустить этот шаг и получить паттерн, который формально работает, но не даёт Dashboards временную ось. Для логов и метрик это означает, что диапазон времени в Discover, тайм-бакеты в визуализациях и сортировка по свежести перестают работать предсказуемо (см. раздел про index patterns — там же @timestamp объявлен как date в маппинге, что и позволяет выбрать его как time field). Если данные временные — time field нужно указывать всегда, даже если мастер не настаивает.
  • Тяжёлые визуализации без ограничения диапазона времени. Агрегация под визуализацией или сохранённым поиском в Discover считается по тому диапазону времени, что задан в верхней панели, — но ничто не мешает выставить диапазон в «last 5 years» или снять его вовсе и запустить terms/date_histogram по всему индексу, а не по окну в несколько часов. На небольшом демо-индексе разницы не видно, на боевом кластере с миллионами документов такая агрегация нагружает кластер ощутимо сильнее, чем то же самое в узком окне (см. раздел про Discover и раздел про визуализации). Привычка сузить диапазон и добавить фильтр перед тяжёлой агрегацией — не оптимизация «на будущее», а способ не положить кластер лишним запросом.
  • Права: пользователь видит не те индексы или не тот tenant. Две независимые причины путаницы с доступом. Первая — роль с index_patterns, которая шире, чем нужно: app-logs-* вместо, скажем, app-logs-prod-*, и reader внезапно видит индексы соседнего окружения. Вторая — перепутанный tenant: Private и Global — это два разных пространства saved objects, и index pattern или дашборд, сохранённый в Private одним пользователем, для другого выглядит как «пропавший», хотя физически никуда не делся — он просто лежит в другом tenant’е (мост к разделу про доступ по ролям, где разбирается и сама изоляция по индексам, и контекст requestedTenant в security exception).
  • Saved objects перенесены без зависимостей. Импорт одного dashboard без visualization и index-pattern, на которые он ссылается, — типичная ошибка при ручном переносе конфигурации: дашборд импортируется, но открывается со сломанными панелями, потому что references указывают на id, которых в целевом .opensearch_dashboards ещё нет. Экспорт всегда стоит делать с includeReferencesDeep: true, чтобы вместе с запрошенным объектом выгружалась вся цепочка зависимостей, а не только он один (мост к разделу про saved objects, где разобран реальный экспорт с missingRefCount: 0 — именно это поле в сводке и стоит проверять после импорта на новом окружении).

Что дальше

Discover, визуализации, дашборды и saved objects закрывают работу с данными, которые уже лежат в индексах в явном, структурированном виде — точное совпадение по полю или полнотекстовый поиск по токенам. За рамками остаётся другой класс задач: поиск по смыслу, а не по совпадению слов, — семантический и векторный поиск через k-NN и эмбеддинги. Этому посвящена следующая, финальная статья серии — «Семантический поиск в OpenSearch». Alerting, anomaly detection и reporting — тоже часть Dashboards, но отдельные плагины со своей логикой, и в этой статье они не раскрываются.

Источники

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

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

Комментарии