Grafana на Kubernetes: ловушка SQLite и EFS

Симптомы

Однажды утром наш экземпляр Grafana для мониторинга начал работать медленно. Дашборды грузились дольше 5 секунд, API-запросы зависали по таймауту, один из трёх подов завис в состоянии CrashLoopBackOff.

Логи говорили сами за себя:

level=error msg="database is locked"
level=error msg="Datasource provisioning error: database is locked"
level=warn msg="cleanup jobs duration" duration_secs=4.4
level=warn msg="saving alert rule state: database is locked"

Сотни ошибок database is locked в день. Каждый путь записи в Grafana — провизионирование дашбордов, правила алертов, задачи очистки, управление сессиями — либо завершался с ошибкой, либо уходил на повтор.

Мы запускали Grafana 12.x на Kubernetes, и на первый взгляд всё выглядело разумно: 3 реплики за HorizontalPodAutoscaler, общий постоянный том на Amazon EFS, чтобы все поды могли читать и писать одни и те же данные.

Оказалось, что каждый элемент этой архитектуры был ошибкой.

Grafana на Kubernetes — диаграмма архитектуры с общим хранилищем EFS

Что Grafana использует для хранения данных

Вот что удивляет многих, кто разворачивает Grafana в Kubernetes: по умолчанию Grafana использует SQLite. Не PostgreSQL, не MySQL — встроенную однофайловую базу данных, расположенную по пути /var/lib/grafana/grafana.db.

Для одиночного экземпляра это работает отлично. SQLite быстрая, надёжная и не требует обслуживания. Но у неё есть фундаментальное ограничение, критичное в распределённых средах:

«Несколько процессов могут одновременно открыть одну и ту же базу данных. Несколько процессов могут одновременно выполнять SELECT. Но в любой момент времени только один процесс может вносить изменения в базу данных.» — SQLite FAQ

SQLite — это база данных с одним писателем (single-writer database). Для координации записей она использует блокировки на уровне файла: одновременно писать может только один процесс. Когда вы кладёте этот файл на общее сетевое хранилище и направляете на него несколько подов, вы заставляете SQLite координировать запись через сеть. Именно здесь всё и ломается.

Проблема NFS

Amazon EFS — это по сути управляемый NFS (NFSv4.1). Для многих задач он прекрасно подходит: общие конфигурационные файлы, медиафайлы, агрегация логов. Но только не для SQLite.

Документация SQLite прямолинейна:

«SQLite рассчитывает на то, что файловая система реализует блокировки именно так, как это описано в её документации. Однако в некоторых файловых системах встречаются ошибки в логике блокировок, из-за которых те работают не так, как задокументировано. Особенно это характерно для сетевых файловых систем и NFS в частности.» — How To Corrupt An SQLite Database File, раздел 2.1

Справедливости ради: это предупреждение написано прежде всего о NFSv3, у которого блокировки были печально известны своей ненадёжностью (они зависели от отдельного демона lockd, который работал нестабильно). NFSv4 — именно его реализует EFS — исправил проблему корректности. Блокировки работают. Они работают правильно… но медленно.

Каждый захват блокировки — это сетевой round-trip. Каждый fsync — тоже сетевой round-trip. На локальном блочном хранилище (например, EBS) эти операции занимают менее 1 миллисекунды. На EFS — от 10 до 50 миллисекунд.

Звучит не страшно. Но путь записи в SQLite выглядит вот так:

  1. Захват блокировки записи → 10–50 мс (сеть)

  2. Запись данных → быстро (буфер)

  3. fsync журнала → 10–50 мс (сеть)

  4. Запись данных в файл базы → быстро (буфер)

  5. fsync базы данных → 10–50 мс (сеть)

  6. Снятие блокировки записи → 10–50 мс (сеть)

Одна транзакция записи, которая на локальном хранилище занимает менее 2 мс, на NFS растягивается до 40–200 мс. Умножьте это на три пода, одновременно пытающихся писать, — и получите очередь процессов, ожидающих блокировки, завершающихся по таймауту (дефолтный busy timeout SQLite) и пишущих в лог database is locked.

Ловушка HPA

Наша конфигурация делала ситуацию ещё хуже. Для Grafana был настроен HorizontalPodAutoscaler (HPA) с minReplicas: 1 и maxReplicas: 3, ориентированный на 60% использования памяти.

Из-за конкуренции за блокировки горутины накапливались в ожидании доступа к базе данных, и потребление памяти росло. Каждый под использовал около 800 МиБ при лимите в 1 ГиБ. HPA видел 80% утилизации и добросовестно масштабировался до 3 реплик.

Больше реплик означало больше писателей, борющихся за одну блокировку SQLite, а значит — больше памяти, занятой ожидающими горутинами, а значит — HPA масштабировался ещё выше. Идеальная петля обратной связи.

Скрытый штраф Infrequent Access

В Amazon EFS есть функция оптимизации затрат — Infrequent Access (IA, редкий доступ). Файлы, к которым не обращались в течение заданного периода (в нашем случае 7 дней), автоматически переносятся на более дешёвый уровень хранения. Звучит как хорошая экономия.

Но чтение файлов из IA-уровня добавляет дополнительные 100–200 мс задержки при первом обращении — поверх и без того медленных NFS-операций. Когда Grafana перезапускалась и нужно было выполнить миграции SQLite для файла grafana.db, который несколько дней пролежал в холодном IA-уровне, время старта было мучительно долгим.

IA-уровень никак не отражается в метриках Kubernetes или логах Grafana. Его замечают только как загадочную дополнительную медлительность, которая то появляется, то исчезает в зависимости от паттернов доступа.

Решение: одна реплика и локальное хранилище

Решение оказалось до неловкого простым: перейти с EFS (NFS, ReadWriteMany) на EBS (блочное хранилище, ReadWriteOnce) и запустить одну реплику.

Потребление памяти упало на 60% — не потому, что мы изменили какую-либо конфигурацию ресурсов, а просто потому, что горутин, накапливавшихся в ожидании блокировки, больше не существует.

Правильный способ обеспечить высокую доступность

Переход на одну реплику с блочным хранилищем решает непосредственную проблему производительности, но жертвует высокой доступностью (HA). Для многих команд, особенно тех, кто использует Grafana как вспомогательный инструмент наблюдаемости, это приемлемый компромисс — быстрое восстановление (около 7 секунд старта) делает кратковременную недоступность несущественной.

Но если организации требуется настоящий HA Grafana без простоев, ответ — не общее NFS-хранилище. Правильный подход таков:

Используйте полноценную клиент-серверную СУБД

Для мультиинстансных развёртываний Grafana официально поддерживает PostgreSQL и MySQL.

При использовании внешней базы данных:

  • Несколько реплик Grafana могут читать и писать одновременно без конкуренции за блокировки

  • База данных сама управляет репликацией и переключением при сбое (например, RDS Multi-AZ)

  • Общая файловая система не нужна — каждому поду достаточно локального или временного хранилища для кэшей

  • Горизонтальное масштабирование действительно работает

Нюанс NFSv4

Распространённый контраргумент звучит так: «Но мы используем NFSv4, а он исправил проблемы с блокировками NFS».

Это наполовину верно. NFSv4 действительно исправил корректность блокировок NFS. В NFSv3 рекомендательная блокировка (advisory locking) опиралась на отдельный демон lockd, снискавший дурную славу из-за своей ненадёжности — блокировки могли молча давать сбой, что приводило к реальной порче базы данных. В NFSv4 обязательная блокировка байтовых диапазонов (mandatory byte-range locking) встроена прямо в протокол. Блокировки работают. Они работают корректно.

Но они работают через сеть. А для SQLite, которая синхронно вызывает fcntl() и fsync() при каждой транзакции, «корректно, но медленно» лишь ненамного лучше, чем «сломано». Порчи данных вы не получите. Зато получите таймауты.

Это расследование началось с «Grafana работает медленно» и завершилось перепроектированием хранилища на 8 кластерах, очисткой брошенных ресурсов, накопившихся за годы, и переписыванием скриптов резервного копирования. Порой самые незначительные симптомы обнажают самые глубокие проблемы.

© 2026 meganuke