Привет, Хабр! Меня зовут Алексей Волков, я руковожу группой core-разработки внутреннего облака VK — One-cloud. Хочу поделиться примерами из эксплуатации: какие были проблемы в проде на Java при высоких нагрузках, как мы это чинили и какие выводы сделали. Никакой теории на бумаге, только реальные истории из жизни крупной облачной платформы. 

Небольшой экскурс в историю

One-cloud появился в мае 2015 года, когда возникла идея эффективного управления инфраструктурой, на которой работают сервисы. В марте 2016 года появился первый релиз. К 2017 году Одноклассники уже полностью работали в облаке. Постепенно мы начали переводить в него все продукты VK. Мои коллеги уже писали об этом, например, ребята из рекламного движка рассказывали, как они переезжали с baremetal в облако, что исправляли и как меняли архитектуру.

При чём здесь Java

Управляющий слой One-cloud на 90 % написан на Java. Связь с Java-экосистемой здесь самая прямая.

Какие задачи стоят перед управляющим слоем? Пользователи описывают желаемое состояние в виде манифестов: сколько ресурсов нужно контейнеру, какой образ использовать, какие диски подмонтировать, сколько реплик, как настроить сеть. Облако по этим манифестам реализует конфигурацию на железе. Следит за тем, чтобы сервис работал, ничего не падало, а если что-то упало — ��однимает.

Управляющий слой представляет собой кластер из трёх нод с Embedded Cassandra. Cassandra запускается во встроенном режиме, то есть и бизнес-логика, и база данных находятся в одном Java-процессе. Из кластера выбирается лидер, который управляет облаком. 

На данный момент каждая нода — это процесс на Java 21 с выделенными 48 гигабайтами оперативной памяти и 40-80 CPU. 

Внутри работает шедулер, который следит за контейнерами, накатывает обновления, останавливает и мигрирует их. Также есть REST API для пользователей и DNS-сервер. В нашем облаке каждый контейнер получает статический IP-адрес и собственное доменное имя — это концептуальное отличие от Kubernetes, где адресация работает динамически.

Что происходит, когда «заезжают все»

С 2017 года облако успело многое пережить. Система отлажена, но с новыми пользователями возрастают нагрузки, появляются неожиданные сценарии использования. Процессы, которые раньше работали стабильно, могут внезапно отказать. Пользователи допускают ошибки и запрашивают новые возможности.

Дальше — несколько реальных примеров из эксплуатации. 

Пример первый: «Я что-то нажал, и всё исчезло»

Однажды, без каких-либо обновлений с нашей стороны, в рабочий чат пришли три оповещения. Суть их была в том, что три ЦОДа не управляются, с мастерами что-то не так.

Сняли профиль с помощью  async-profiler’а и увидели, что почти всё время уходит на сборку мусора. К слову, есть два способа получить такую гистограмму: с помощью GMAP, передав ей PID процессов, либо из журналов, если изначально у вас задана специальная опция.

> asprof -d 60 -e cpu -f cpu.html {pid}

Это означало, что JVM постоянно находится в состоянии OutOfMemory. Логи переполнены гистограммами — у нас при старте включено журналирование статистики GC через JVM-опцию.

В логах мы увидели такие гистограммы:

1:     609806622    24392264880     java.util.HashMap$Node (java.base@17.0.1)
2:      86700037      12568809184    [Ljava.util.HashMap$Node; (java.base@17.0.1)
3:      86655750         5545968000     java.util.HashMap (java.base@17.0.1)
4:       2039111           3159748872    [Ljava.lang.Object; (java.base@17.0.1)
5:      10237994         1098777400    [B (java.base@17.0.1)
6:       9832304             314633728    java.lang.String (java.base@17.0.1)
7:       2455822             192907064    [Lio.micrometer.core.instrument.Tag;
8:       2398273            177831992     [Ljava.lang.String; (java.base@17.0.1)
9:       5138378            164428096     io.micrometer.core.instrument.ImmutableTag
10:       2455818          157172352     io.micrometer.core.instrument.Meter$Id

Нам показалось странным, что достаточно много объектов в логах имеют отношения к метрикам. Дело в том, что мы мигрировали с внутренней системы метрик Charts на Victoria Metrics с Grafana. Слева — charts, справа — Grafana c VM:

Начали разбираться, откуда столько метрик. Скрейперы Victoria собирали почти 300 тысяч метрик. Появилась характерная пила: какое-то время метрик мало, потом всплеск — метрик становится много, происходит пред-OutOfMemory, лидер облака меняется, и цикл повторяется.

Отключили выдачу метрик — и всё заработало. Но без них жить нельзя, поэтому мы начали разбираться, откуда взялось такое количество. Оказалось, что раз в 10 минут лидер собирал множество метрик о том, как он управляет сервисами, — по сути, это были характеристики внутреннего состояния сервиса на лидере облака. После этого запрос раздувался до 130 МБ, которые возвращались скрейперам.

Сбор этих данных отключили программно — кластер стабилизировался.

В спокойном режиме сняли профиль аллокаций: сбор заполнял heap почти так же, как весь остальной REST API.

Снимок экрана 2025-07-19 в 22.00.23.png
Снимок экрана 2025-07-19 в 22.00.23.png

Далее выяснили: при масштабировании скрейперов VictoriaMetrics возникла рассинхронизация. Вместо единого опроса раз в 10 секунд каждый скрейпер начал ходить самостоятельно — нагрузка выросла в 15 раз.

Казалось, на этом всё, но история продолжилась. При профилировании CPU в режиме с разбивкой по тредам обнаружили: множество потоков зависало на Thread.sleep.

Снимок экрана 2025-08-17 в 18.26.18.png
Снимок экрана 2025-08-17 в 18.26.18.png

В какой-то момент, мы хотели защитить трафик для управления облаком от трафика в API. Для этого в Jetty использовали Network Traffic Listener с лимитером из Guava. 

Проблема заключалась вот в чём: при превышении лимита метод acquire вызывал Thread.sleep(). Настройки долгое время не обновляли, лимит стоял на 500 Мбит/с. Расчёт показал, что облако во время аварии выдавало 750 Мбит/с. только метрик. Лимитер работал на полную во время инцидента. С одной стороны, мы получили Prometheus, который активно мусорил в heap, с другой — лимитер, который не давал ресурсам освобождаться.

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

Такие ситуации, как оказалось, достаточно сложно отлавливать. Если смотреть на аппаратные метрики потребления сети, то кажется, что всё в порядке, хотя на этом графике лимитер уже работает на полную мощность.

Пересмотрели лимитер, проверили запросы с тяжёлыми ответами, вынесли сбор тяжёлых метрик в отдельный эндпоинт, который реже опрашивали скрейперы. Переписали форматтер: конкатенация строк работала эффективнее, чем Prometheus-writer. Потом заметили, что на GitHub есть открытый баг в сериализации ответа во writer-е. Все настройки лимитеров сделали конфигурируемыми на лету — раньше они были прописаны в файле, который лежал на сервере, и в интерфейсе его даже не было видно.

Выдача метрик может быть достаточно дорогой операцией. Необходимо следить за их количеством, оно может незаметно расти. Если нужно что-то отправлять в ответе, то имеет смысл замерить производительность и, возможно, переписать форматтер. 

Пример второй: «Ресурсы есть, а ничего не работает»

Источник правды о состоянии контейнеров — отчёты, приходящие с хостов лидеру облака. Эти данные живут в памяти, откуда черпают информацию шедулер и REST API. Лидер облака является источником правды об ожидаемом состоянии, то есть о манифестах. 

Для безопасного общения и потокобезопасности (чтобы принимать верные управляющие решения) мы часто применяли паттерн Coarse-Grained Lock, когда lock захватывается на нескольких объектах. Это было оправдано, потому что цена гонки или ошибки для облака очень велика.

Однажды мы начали замечать, что шедулер чего-то ждёт. Ресурсов процессора хватает, память есть, GC работает штатно, но по графикам видны провалы в работе шедулера. Вроде работает, а вроде и нет.

В таких ситуациях эффективно снимать профили в режиме wall-clock — он позволяет увидеть различные особенности работы с блокировками. В профиле было видно, что шедулер каждый раз упирался в какую-то блокировку, которая захватывает запрос из REST API. Оказалось, что Coarse-Grained Lock вызывал взаимные блокировки между шедулером и REST API. 

За несколько итераций удалось распутать узел взаимных блокировок, после чего шедулер и REST API зажили в гармонии.

Но это был не финал. В нагруженных ЦОДах треды JVM на лидере облака выглядели так:

Проблема крылась в публикации данных из шедулера в REST API. Когда шедулер заканчивал свой управленческий цикл, он публиковал данные о состоянии в так называемый snapshot под глобальной блокировкой:

// из шедулера
synchronized (snapshot) {
    snapshot.update(data);
}

С другой стороны, этот snapshot читался во время обработки REST-запросов, тоже под глобальной блокировкой. Более того, какие-то запросы создавали копию snapshot-а, чтобы иметь возможность проиграть на нём какие-то изменения, и это создание копии в связи с ростом облака начало занимать секунды:

// из REST API

SnapshotService state = null;
synchronized (snapshot) {
    state = snapshot.latest(requestCopy);
}

Мы переписали этот код на атомики:

// из шедулера
var snapshot = lastStateSnapshot.get();

// бизнес-логика
var newSnapshot = data.snapshot();
var result = lastStateSnapshot.compareAndSet(snapshot, newSnapshot);
assert result;

// из REST API
lastStateSnapshot.get().latest(requestCopy);

После релиза заблокированные потоки почти исчезли, и бекенд стал обрабатывать на 20 % больше запросов.

Когда работаешь со сложным многопоточным кодом, ошибки неизбежны. Мы тоже столкнулись с ними, и какое-то время разбирались с взаимными блокировками.

На лидере облака есть несколько ресурсов, которые позволяли получать события по SSE. События публиковались из шедулера облака. Как только мы ослабили взаимные блокировки между шедулером и REST-ом, они стали возникать в SSE: с одной стороны, шедулер публиковал данные, с другой — в момент смены лидерства SSE-соединение пыталось разорваться. Возникала гонка, и оба треда сталкивались с взаимной блокировкой.

Есть несколько механизмов, которые позволяют диагностировать ситуацию и быстро снизить последствия. JStack сразу покажет, какие треды на каких объектах блокируются. Можно настроить Tanuki wrapper так, чтобы процесс сам себя перезапускал:

wrapper.check.deadlock=TRUE
wrapper.check.deadlock.interval=60
wrapper.check.deadlock.action=RESTART
wrapper.check.deadlock.output=FULL

Код из стандартного Java API покажет, что есть взаимная блокировка:

ManagementFactory.getThreadMXBean().findDeadlockedThreads()

Пока мы пытались аккуратно исправить код, чтобы ничего не сломать, решили воспользоваться опцией «харакири», которая перезапускала Java-процесс в случае взаимной блокировки. 

Когда казалось, что все исправлено и код работает так, как должен, нам снова упало оповещение про взаимную блокировку. На этот раз баг был не в нашем коде, а в коде реализации SSE в Spring Framework, который был исправлен в обновлении.

Выводы

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

Даже если продукт стабилен, при его росте будут новые вызовы. Полезно уметь пользоваться разными инструментами для анализа производительности: профилировщик, анализ состояния тредов, анализ heap-дампов (осталось за кадром). Это помогает в критические моменты.

Удачная архитектура позволяет улучшать код адресно. Несмотря на все неприятные ситуации, наша архитектура позволила обработать их без переписывания всего продукта.

И последний совет: собирайте артефакты, чтобы потом можно было рассказать о них.