Привет! Я Сергей Истомин, 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 в чистом виде и представим следующий сценарий.

  1. Кто-то когда-то провел деплой дашбордов.

  2. Права на редактирование выдали ответственному сотруднику от бизнеса.

  3. Сотрудник корректировал дашборд под свои нужды, не подозревая, что в один момент может все потерять (забавно, что размеры потерь напрямую определяются через ResyncPeriod).

  4. Еще до истечения ResyncPeriod он решает реорганизовать дашборды, перемещает свой отредактированный… и все, дашборда нет, а на его месте вновь появился прародитель-исходник.

Или другой вариант: у борда, задеплоенного через .spec.url, изменилось содержимое — и изменения сразу применяются, перечеркивая старания того самого сотрудника. От таких сценариев Grafana Operator не только не защищает, но даже в какой-то степени спо��обствует их возникновению.

Получается, что цели использования GrafanaDashboard ограничиваются простой доставкой дашборда до Графаны. Где-то, видимо, есть целевая аудитория этого процесса (наверное, пользователи облачной grafana.com). В конце концов, не всем же необходим неизменяемый провижининг?

В своем блоге авторы пишут, что у продукта миллионы загрузок, но толку от этого, по ощущениям, немного. Проект живет с 2019 года, уже вышла версия 5.21. Стоит ли ждать дня, когда Grafana Operator догонит уже имеющиеся «классические» варианты деплоя дашбордов? Скорее нет, чем да, но через годик проверим.

Кто во всем виноват — этот вопрос, надеюсь, вам не интересен. А вот что с этим всем делать?

Скажем так: если вы хотите по-настоящему управляемый деплой датасорсов и дашбордов, то Grafana Operator вам не помощник. Стройте все на провижининге файлами через деплой конфигмапов, их чтение и доставку в Графану сайдкарами. Единственное, что может нам дать Grafana Operator — так это подключение дашбордов по ID с сайта Графаны или по URL. При этом важно устанавливать для них минимальный ResyncPeriod (несколько секунд, но не нулевой, а то ресинк останется только при перезапуске пода Оператора).

Так что результаты экспериментов с Grafana Operator, очевидно, неутешительны. Зато об удачных экспериментах можно почитать в других статьях нашей команды. Рекомендую: