
Привет! Я Сергей Истомин, DevOps-инженер в KTS.
Хотите удобно деплоить дашборды и датасорсы в концепции IAC? Я тоже.
Услышав про Grafana Operator, я, окрыленный надеждами, пошел к нему за решением. Но у Grafana Operator оказалось свое представление о деплое, о провижининге и о том, как он вообще должен работать.
Какие картины рисуются в голове инженера, когда он слышит слово «оператор» в связке с названием платформы? Не отвечу за всех, но в моей фантазии все выглядело круто и удобно. И я побежал смотреть на сайт Графаны, а там… батюшки мои, там и kind: Grafana, и kind: GrafanaDatasource, и kind: GrafanaDashboard. Восторг наполнил мои полушария, руки зачесались, и так я поставил Оператора.
Выяснилось, что я все-таки неисправимый оптимист по натуре. Но обо всем по порядку.
Оглавление
Стенд
Для начала опишу мой лабораторный стенд:
Kubernetes-кластер v1.32.1;
Victoria-Metrics Operator v0.66.1, манифесты
vmclusterиvmagent— минимальный набор;чашка кофе.
Для оператора поднимаю maxConcurrentReconciles с 1 до 10 и сокращаю defaultResyncPeriod с 10 минут до 2 секунд, чтобы ускорить эксперименты.
helm repo add grafana https://grafana.github.io/helm-charts helm repo update helm upgrade -i grafana-operator grafana/grafana-operator \ --namespace grafana --create-namespace \ --version 5.21.4 \ --set maxConcurrentReconciles=10 \ --set defaultResyncPeriod=2s kubectl config set-context --current --namespace=grafana
Следующий шаг, как объяснил мне сайт Графаны — это окунуться в документацию самого Оператора. Без раздумий я отправился в секцию с примерами. Примеры в документации на порядок быстрее инструкций раскрывают возможности продукта, поэтому я всегда стараюсь найти именно их. И так я сделал несколько объектов на базе примеров к Оператору.
Итерация первая
Манифесты
# Простой манифест дефолтной Графаны. # Определим лишь лейбл для дальнейшего маппинга на этот экземпляр датасорсов, папок и дашбордов, и определим логин и пароль администратора. apiVersion: grafana.integreatly.org/v1beta1 kind: Grafana metadata: name: grafana labels: instance: the-grafana spec: config: security: admin_user: root admin_password: secret --- # Манифест датасорса. Необходимо указать в .metadata.instanceSelector лейбл вашего экземпляра. # У меня это пара `instance: the-grafana` apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDatasource metadata: name: basic spec: instanceSelector: matchLabels: instance: the-grafana datasource: name: "Basic (kind: GrafanaDatasource)" type: prometheus access: proxy url: http://vmselect-vm:8481/select/0/prometheus isDefault: false editable: true --- # Создадим простую папку для дашбордов apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaFolder metadata: name: basic-folder spec: instanceSelector: matchLabels: instance: the-grafana title: My dashboards --- # Создадим вложенную папку для дашбордов. # Озаботимся установкой прав. Дашборды этой папки унаследуют права. # Есть важный момент: для роли Admin вы ничего переопределить не сможете. apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaFolder metadata: name: subfolder spec: title: Subfolder parentFolderRef: basic-folder instanceSelector: matchLabels: instance: the-grafana permissions: | { "items": [ { "role": "Editor", "permission": 1 }, { "role": "Viewer", "permission": 1 } ] } --- # Первый дашборд. Папку не указываем - получаем деплой в корень. # Приятная возможность для деплоя через оператора - импорт дашборда по номеру с grafana.com # Опционально можно указать и revision для дашборда, чтобы не промахнуться. apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: name: vmcluster spec: instanceSelector: matchLabels: instance: the-grafana grafanaCom: id: 11176 revision: 50 --- # Второй дашборд. # Укажем целевое размещение в определенной папке. # Указывается папка по имени объекта (basic-folder), а не по имени папки в UI Графаны (My dashboards). # А в спецификации - импорт дашборда по URL. apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: name: url-board spec: folderRef: basic-folder instanceSelector: matchLabels: instance: the-grafana url: "https://raw.githubusercontent.com/grafana/grafana-operator/refs/heads/master/examples/dashboard/url/dashboard.json" --- # Третий дашборд. # Развернем дашборд из заранее подготовленного json, сжатого gzip и закодированного base64. # Не самый дружественный способ деплоя, как по мне, но почему бы и нет. # Деплоим во вложенную папку. apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: name: gzip-board spec: folderRef: subfolder instanceSelector: matchLabels: instance: the-grafana gzipJson: |- H4sIAAAAAAAAA4WQQU/DMAyF7/0VVc9MggMgcYV/AOKC0OQubmM1jSPH28Sm/XfSNJ1WcaA3f+/l+dXnqk5fQ6Z5qf3eubt5VlKHCTXvNAaH9RtE2zKI2fQnCgFNsxihj8n39V3mqD/zQwMyXE004ol95q3wMaIsEhpSaPMTlT0WasngK3sVdlN6By4uUi8Q7AezUwpJeig4gEe3ajItTfM5T5l0wuNUwfNx82RLg9nLhTeZXW4iAu2GVHcVNPEtByX2tyuzJtgJRrslrygHKJ3WsZhuCkq+X8c6ivrXDd6zwrLrX3vZP/3PY1yuHHcWR/hEiSlmutpzEQ5XdF+IIz+Uzpeq+gWtMMT1HwIAAA== --- # Четвертый дашборд. # Собственно, деплой вот прям дашборда как есть: в спецификации читаемый json. # Здесь не используем заранее подготовленные папки, а указываем папку прямо тут - попробуем такой способ. apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: name: grafanadashboard-sample spec: folder: "Custom Folder" instanceSelector: matchLabels: instance: the-grafana json: > { "id": null, "title": "Simple Dashboard", "tags": [], "style": "dark", "timezone": "browser", "editable": true, "hideControls": false, "graphTooltip": 1, "panels": [], "time": { "from": "now-6h", "to": "now" }, "timepicker": { "time_options": [], "refresh_intervals": [] }, "templating": { "list": [] }, "annotations": { "list": [] }, "refresh": "5s", "schemaVersion": 17, "version": 0, "links": [] } --- # И пятый способ деплоя дашборда в моем эксперименте. # Отдельно манифест на конфигмап с дашбордом, отдельно манифест объекта GrafanaDashboard. # Через configMapRef указываем в каком конфигмапе, какой ключ брать. apiVersion: v1 kind: ConfigMap metadata: name: dashboard-definition data: json: > { "id": null, "title": "Simple Dashboard from ConfigMap", "tags": [], "style": "dark", "timezone": "browser", "editable": true, "hideControls": false, "graphTooltip": 1, "panels": [], "time": { "from": "now-6h", "to": "now" }, "timepicker": { "time_options": [], "refresh_intervals": [] }, "templating": { "list": [] }, "annotations": { "list": [] }, "refresh": "5s", "schemaVersion": 17, "version": 0, "links": [] } --- apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: name: grafanadashboard-from-configmap spec: folder: "" instanceSelector: matchLabels: instance: the-grafana configMapRef: name: dashboard-definition key: json
Деплоим, шатаем, ломаем, проверяем.
Результаты
Получаем такое поведение объектов (с отсрочкой на resyncPeriod).
Цветовая легенда:
зеленое — ожидаемое поведение;
желтое — опасное поведение, но все еще местами терпимое;
красное — однозначно опасное поведение.

Анализ
Впервые я проделывал это упражнение летом 2025 года, и тогда красных ячеек было больше. Все дашборды через деплой объекта GrafanaDashboard болели тем, что не откатывались к начальной версии после редактирования. Оператор поправили, и сегодня (начало 2026) ситуация намного лучше, но все еще остается опасной.
Почему я все еще считаю, что стоит покрасить в красный целый столбец? Тут есть ловушка. Исправления, легально внесенные через UI разработчиками/админами, будут бесследно и без предупреждения удалены через какой-то период. Параметр defaultResyncPeriod по умолчанию установлен в 10 минут, а при деплое дашборда кто-то запросто выставит ResyncPeriod и в 1 час, и в 1 день, не подозревая, что копает коллеге яму.
Есть условно компромиссный вариант — выставлять дашбордам режим readonly (вхождением в readonly-папку), и тогда дашборды не сможет редактировать никто. Кроме группы Admin, для которой вы readonly не установите. Поэтому все пользователи с правами админа должны быть осведомлены об автоматическом сбросе изменений. И даже в таком случае readonly остается ��енее гибким вариантом, чем запрет на сохранение с предложением экспорта изменений в JSON, что можно реализовать при провижининге файлами.
Решение все же есть, и да, это провижининг файлами. Через Оператора можно указать для Графаны сайдкар (репозиторий, образы).
Итерация вторая
Мне понадобится Графана с сайдкар-контейнерами для провижининга дашбордов и датасорсов. По большому счету, от Оператора тут остается только создание Графаны.
Удаляем предыдущие манифесты и деплоим новые.
Новые манифесты
# Нам понадобятся права для сервисного аккаунта, который автоматически создаст Оператор для нашего инстанса Графаны и назовет grafana-sa. # Будем наблюдать и читать конфигмапы в кластере в поисках датасорсов и дашбордов. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: grafana-sidecar-role rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "watch", "list"] --- # Назначаем созданную роль на сервисный аккаунт. kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 metadata: name: grafana-sidecar-rolebinding roleRef: kind: ClusterRole name: grafana-sidecar-role apiGroup: rbac.authorization.k8s.io subjects: - kind: ServiceAccount name: grafana-sa namespace: grafana --- # Создаем конфигмап, но не с дашбордом, а пока только с правилами провижининга дашбордов. # Будем на эти правила монтировать файлом в инстанс Графаны. apiVersion: v1 kind: ConfigMap metadata: name: dashboards-provisioning-configmap data: provisioning-config.yaml: |- apiVersion: 1 providers: - name: dashboards-provisioning-config orgId: 1 type: file disableDeletion: false updateIntervalSeconds: 5 allowUiUpdates: false options: path: /var/lib/grafana/dashboards foldersFromFilesStructure: true --- # Секрет с логином и паролем для Графаны. # Укажем на него в спецификации манифеста Графаны. kind: Secret apiVersion: v1 metadata: name: grafana-admin-credentials stringData: GF_SECURITY_ADMIN_USER: root GF_SECURITY_ADMIN_PASSWORD: secret type: Opaque --- # Манифест Графаны. # Комментарии будут по телу манифеста. apiVersion: grafana.integreatly.org/v1beta1 kind: Grafana metadata: name: grafana spec: # Будем использовать свой секрет, так что дефолтный нам не нужен disableDefaultAdminSecret: true # Лезем глубже - описываем volumes и containers deployment: spec: template: spec: volumes: # декларируем volume с конфигом правил провижининга дашбордов - name: dashboards-provisioning-config configMap: name: dashboards-provisioning-configmap defaultMode: 420 # декларируем volume-заглушки для пустых папок, куда полетят дашборды и датасорсы - name: dashboards emptyDir: {} - name: datasources emptyDir: {} containers: # описываем контейнер grafana. Выбора имени у вас нет, тут это именно указатель, что идет описание именно контейнера grafana - name: grafana image: docker.io/grafana/grafana:12.3.1 # Указываем на создание переменных окружения для конфигурирования пары логин-пароль администратора. # Забавно, но способ (envFrom с указанием на secretRef.name) вызывает у Оператора некий дискомфорт: хоть деплоймент и успешно создается, # но stage status для команды kubectl get grafanas.grafana.integreatly.org выдает failed. # А если использовать способ env.valueFrom.secretKeyRef, то увидите success. # Не будем раздражать Оператора :) env: - name: GF_SECURITY_ADMIN_USER valueFrom: secretKeyRef: name: grafana-admin-credentials key: GF_SECURITY_ADMIN_USER - name: GF_SECURITY_ADMIN_PASSWORD valueFrom: secretKeyRef: name: grafana-admin-credentials key: GF_SECURITY_ADMIN_PASSWORD # Монтируем volume volumeMounts: - name: dashboards-provisioning-config mountPath: /etc/grafana/provisioning/dashboards - name: dashboards mountPath: /var/lib/grafana/dashboards - name: datasources mountPath: /etc/grafana/provisioning/datasources # Ниже два контейнера, создающие всю магию. # Для этого нужно передать пачку переменных окружения и место монтирования объекта. # Контейнер выполняет в рамках прав сервисного аккаунта (grafana-sa) поиск RESOURCE (configmap), # у которого есть лейбл с ключом LABEL (grafana_dashboard) и значением LABEL_VALUE ('1'); # затем контейнер выполнит создание файла с именем по ключу вашего RESOURCE и соответствующим # содержимым в примонтированной директории, а если у вас будет в вашем RESOURCE аннотация с ключом # FOLDER_ANNOTATION, то монтирование будет выполнено в созданную директорию по значению такой аннотации. # Далее вступает в игру конфиг провижининга в Графане, который описывает, как именно отобразить файлы-дашборды - # отрабатывает foldersFromFilesStructure: true, и вы видите в UI папку и дашборд в ней. - name: dashboards-discoverer image: quay.io/kiwigrid/k8s-sidecar:1.30.10 env: - name: METHOD value: WATCH - name: LABEL value: grafana_dashboard - name: LABEL_VALUE value: '1' - name: FOLDER value: /var/lib/grafana/dashboards - name: NAMESPACE value: ALL - name: RESOURCE value: configmap - name: FOLDER_ANNOTATION value: grafana-folder - name: REQ_USERNAME valueFrom: secretKeyRef: name: grafana-admin-credentials key: GF_SECURITY_ADMIN_USER - name: REQ_PASSWORD valueFrom: secretKeyRef: name: grafana-admin-credentials key: GF_SECURITY_ADMIN_PASSWORD - name: REQ_URL value: http://localhost:3000/api/admin/provisioning/dashboards/reload - name: REQ_METHOD value: POST volumeMounts: - name: dashboards mountPath: /var/lib/grafana/dashboards # Здесь картина такая же, как и в предыдущем контейнере. # Стоит отметить, что удаление датасорсов - это либо монтирование удаляющего конфига, # либо указание на prune: true (см. ниже конфигмап file-provisioned-ds). - name: datasources-discoverer image: quay.io/kiwigrid/k8s-sidecar:1.30.10 env: - name: METHOD value: WATCH - name: LABEL value: grafana_datasource - name: LABEL_VALUE value: '1' - name: FOLDER value: /etc/grafana/provisioning/datasources - name: NAMESPACE value: ALL - name: RESOURCE value: configmap - name: REQ_USERNAME valueFrom: secretKeyRef: name: grafana-admin-credentials key: GF_SECURITY_ADMIN_USER - name: REQ_PASSWORD valueFrom: secretKeyRef: name: grafana-admin-credentials key: GF_SECURITY_ADMIN_PASSWORD - name: REQ_URL value: http://localhost:3000/api/admin/provisioning/datasources/reload - name: REQ_METHOD value: POST volumeMounts: - name: datasources mountPath: /etc/grafana/provisioning/datasources --- # Конфигмап с датасорсом. # Обратите внимание, что единственное, что связывает этот конфигмап с задеплоеной Графаной, # так это лейбл (здесь это grafana_datasource), по которому сайдкар в Графане поймет, что нужно его обрабатывать. # Тут нет уже instanceSelector, как это было у объектов GrafanaDatasource. # Внутри конфига есть директива prune: true, которая обеспечит удаление датасорса при удалении запровижиненного файла, # которое, в свою очередь, произойдет при удалении этого конфигмапа. apiVersion: v1 kind: ConfigMap metadata: name: file-provisioned-ds labels: grafana_datasource: '1' data: datasource.yaml: |- apiVersion: 1 prune: true datasources: - name: "Basic (via ConfigMap)" type: prometheus access: proxy url: http://vmselect-vm:8481/select/0/prometheus isDefault: true editable: false --- # Конфигмап с дашбордом. # Указываем лейбл (здесь это grafana_dashboard), по которому сайдкар в Графане поймет, что нужно обрабатывать этот конфигмап. # Закажем папку по значению ожидаемой сайдкаром аннотации (здесь это grafana-folder). apiVersion: v1 kind: ConfigMap metadata: name: sample-configmap labels: grafana_dashboard: "1" annotations: grafana-folder: Hello people! data: dashboard.json: |- { "annotations": { "list": [ { "builtIn": 1, "datasource": { "type": "grafana", "uid": "-- Grafana --" }, "enable": true, "hide": true, "iconColor": "rgba(0, 211, 255, 1)", "name": "Annotations & Alerts", "type": "dashboard" } ] }, "editable": true, "fiscalYearStartMonth": 0, "graphTooltip": 1, "id": 1, "links": [], "panels": [ { "datasource": { "type": "prometheus", "uid": "P4FD674F0F8251845" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisBorderShow": false, "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "barWidthFactor": 0.6, "drawStyle": "line", "fillOpacity": 0, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "insertNulls": false, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "showValues": false, "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } }, "mappings": [], "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": 0 }, { "color": "red", "value": 80 } ] } }, "overrides": [] }, "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "id": 1, "options": { "legend": { "calcs": [], "displayMode": "list", "placement": "bottom", "showLegend": true }, "tooltip": { "hideZeros": false, "mode": "single", "sort": "none" } }, "pluginVersion": "12.3.0", "targets": [ { "datasource": { "type": "prometheus", "uid": "P4FD674F0F8251845" }, "editorMode": "code", "expr": "up", "instant": false, "legendFormat": "__auto", "range": true, "refId": "A" } ], "title": "Новая панель", "type": "timeseries" } ], "preload": false, "refresh": "5s", "schemaVersion": 42, "tags": [], "templating": { "list": [] }, "time": { "from": "now-6h", "to": "now" }, "timepicker": { "refresh_intervals": [] }, "timezone": "browser", "title": "Simple Dashboard", "uid": "827e4648-84da-4c4a-bc4f-0f20c6365ab3", "version": 1 } ---
Деплоим, шатаем, ломаем, проверяем.
Результаты
Получаем обновленное поведение объектов.
Цветовая легенда та же:
зеленое — ожидаемое поведение;
желтое — опасное поведение, но все еще местами терпимое;
красное — однозначно опасное поведение.

Анализ
Работа с датасорсом идеальна при указании в параметрах датасорса editable:false. Работа с дашбордом — при указании в конфиге провижининга disableDeletion: false.
Итог
Вернемся к Grafana Operator в чистом виде и представим следующий сценарий.
Кто-то когда-то провел деплой дашбордов.
Права на редактирование выдали ответственному сотруднику от бизнеса.
Сотрудник корректировал дашборд под свои нужды, не подозревая, что в один момент может все потерять (забавно, что размеры потерь напрямую определяются через
ResyncPeriod).Еще до истечения
ResyncPeriodон решает реорганизовать дашборды, перемещает свой отредактированный… и все, дашборда нет, а на его месте вновь появился прародитель-исходник.
Или другой вариант: у борда, задеплоенного через .spec.url, изменилось содержимое — и изменения сразу применяются, перечеркивая старания того самого сотрудника. От таких сценариев Grafana Operator не только не защищает, но даже в какой-то степени спо��обствует их возникновению.
Получается, что цели использования GrafanaDashboard ограничиваются простой доставкой дашборда до Графаны. Где-то, видимо, есть целевая аудитория этого процесса (наверное, пользователи облачной grafana.com). В конце концов, не всем же необходим неизменяемый провижининг?
В своем блоге авторы пишут, что у продукта миллионы загрузок, но толку от этого, по ощущениям, немного. Проект живет с 2019 года, уже вышла версия 5.21. Стоит ли ждать дня, когда Grafana Operator догонит уже имеющиеся «классические» варианты деплоя дашбордов? Скорее нет, чем да, но через годик проверим.
Кто во всем виноват — этот вопрос, надеюсь, вам не интересен. А вот что с этим всем делать?
Скажем так: если вы хотите по-настоящему управляемый деплой датасорсов и дашбордов, то Grafana Operator вам не помощник. Стройте все на провижининге файлами через деплой конфигмапов, их чтение и доставку в Графану сайдкарами. Единственное, что может нам дать Grafana Operator — так это подключение дашбордов по ID с сайта Графаны или по URL. При этом важно устанавливать для них минимальный ResyncPeriod (несколько секунд, но не нулевой, а то ресинк останется только при перезапуске пода Оператора).
Так что результаты экспериментов с Grafana Operator, очевидно, неутешительны. Зато об удачных экспериментах можно почитать в других статьях нашей команды. Рекомендую:
Тонкости обновления драйверов NVIDIA в Yandex Managed Kubernetes
BuildKit в Kubernetes: мануал по быстрой и автомасштабируемой сборке проектов
GitOps для Airflow: как мы перешли на лёгкий K8s-native Argo Workflows
Infrastructure as Code на практике: как мы рефакторили сложный Ansible-репозиторий (это моя, так что рекомендую особенно).
