
Привет, Хабр! Меня зовут Александр Чесноков, я разработчик команды Platform V DataGrid в СберТехе. Наш продукт — форк Apache Ignite с нашими доработками, и мы активно поддерживаем open‑source версию: контрибьютим в основной репозиторий, исправляем баги, развиваем новые фичи, а также поддерживаем инфраструктуру для тестирования.
В этой статье расскажу, как в Ignite мы столкнулись с orphan JVM на тестовых стендах, как они поломали нам тестирование и как мы решили эту проблему, научив дочерние JVM отслеживать завершение родителя через pipe, используя System.in. В конце разберем конкретную реализацию решения.
Статья будет полезна тем, кто работает с CI, интеграционными тестами, или запускает дочерние процессы в своих приложениях.
Apache Ignite
Apache Ignite — это распределённая in‑memory-система баз данных, поддерживающая datagrid-операции и написанная на Java. Каждый pull request от контрибьюторов идёт сразу в master, поэтому мы прогоняем каждый патч через тесты на TeamCity. У нас более 80 000 тестов, в основном интеграционных. Типичный сценарий: подняли серверный или клиентский узел Ignite, выполнили на нём операции и сравниваем полученные результаты с нашими ожиданиями.
Если хочется глубже погрузиться в тему CI и тестирования в Apache Ignite, то мы уже рассказывали об этом на Хабре — например, как боремся с flaky‑тестами и поддерживаем стабильность прогонов.
Отдельно отмечу совместимость: в Apache Ignite мы заботимся о поддержке взаимодействия различных версий продукта. Например, у нас есть понятие тонкого клиента — это клиентская нода в Ignite, которая может подключаться к серверной ноде любой версии. Также мы сейчас активно разрабатываем механизм Rolling Upgrade, когда в кластере могут одновременно быть серверные ноды двух разных версий. Такая функциональность позволит обновлять кластер без полной остановки.
Чтобы поддерживать совместимость, у нас есть соответствующие тесты. Типичный сценарий выглядит так: в исходной JVM запускаем тест, который запускает клиентскую или серверную ноду, а затем — новую JVM в дочернем процессе, где уже запускается нода старой версии. Старая нода выполняет некие операции, может подключиться к новой, и мы проверяем, чтобы поведение соответствало ожиданиям. Если старая нода падает, то родительская JVM по таймауту завершает запущенный процесс в блоке finally.
Казалось, что такие тесты должны работать как швейцарские часы, но однажды всё пошло не так…
Падение тестов
Мне досталась, на первый взгляд, простая задача: добавить тест на совместимость, который проверяет, что Rolling Upgrade корректно падает для серверной ноды при определённых условиях.
Я написал тест, прогнал его через весь набор — всё прошло идеально и на этом месте история могла бы закончиться.
Однако через неделю мы заметили, что многие тесты начали падать по таймауту. Более того, падали наборы, которые вообще тестировали клиентские узлы, написанные на Python. Основной симптом — ошибка Address already in use, указывающая на занятый порт.
В итоге мы провели небольшое расследование, где вышли на меня и мой коммит. Посмотрели список всех запущенных процессов на тестовых стендах, благо в наших тестах на совместимость мы пишем понятные метки для новых процессов.

Я перепроверил свой тест: он корректно освобождал ресурсы и завершал дочернюю JVM. Тогда я задумался: в каком случае дочерний процесс вообще может остаться живым?
Ответ оказался прост — в том случае, если родительская JVM завершается резко. Я полез в историю прогонов в TeamCity, и тут всё сошлось. После добавления моего теста compatibility‑набор начал выполняться заметно дольше, и в какой‑то момент TeamCity просто принудительно завершал весь запуск.

В этот момент родительская JVM умирала, а дочерние процессы оставались жить — и чистить их уже было некому. Тут я пришёл к следующей задаче: как дочерняя JVM может узнавать о том что её родитель неожиданно завершился и пора завершать работу?
Для простоты мы называли такие забытые дочерние машины zombie JVM, поскольку по аналогии они уже не должны были жить. Однако корректнее говорить, что это orphan-процессы (orphan — сирота): в отличие от zombie-процессов, которые уже завершились, но ещё не «подобраны» родителем, orphan‑процессы продолжают выполняться после завершения родителя (подробнее про различия можно почитать, например, здесь).

Далее я начал думать над решениями.
ProcessHandle
Первой идеей было при запуске дочерней JVM запоминать родительский pid:
long parentPid = ProcessHandle.current() .parent() .map(ProcessHandle::pid) .orElse(-1);
И периодически проверять родителя. Если он поменялся, значит что-то пошло не так и надо завершаться. Мне не понравился этот вариант по двум причинам:
Непонятно, как часто проверять родителя. Конечно, в моей задаче было достаточно проверять каждые 1-5 секунд, но и тратить лишний раз ресурсы на банальную проверку не хотелось.
Возможна ситуация reparenting: когда дочернюю JVM может усыновить другой процесс. Например, parent pid может стать 1 (см. подробности на SO). В итоге может произойти гонка: дочерняя JVM только запустится, родитель сразу остановится и произойдёт reparenting на pid 1, который дочерняя JVM и запомнит как своего родителя.
File as lock
Следующей идеей было создать некий аналог блокировки, но для процессов. Её бы захватывал родитель, а дочерний процесс тоже в цикле пытался бы захватить. Если смог, значит родитель отвалился. В роли такой блокировки можно было взять некий файл, а процессы конкурировали бы за права на запись. Такой подход хотя бы устойчив к reparenting, но мне не нравилась его хрупкость: где именно создавать файл, как его называть, когда удалять? А что если остался файл с предыдущего прогона? Слишком много вопросов и движений для банального parent listening.
Parent pipe listener
Первое решение полностью реализуется пользовательским кодом, а второе делегирует захват блокировки операционной системе. Возникла идея: можно ли как‑то полностью делегировать процесс listening & notification операционной системе, которая явно может дать более строгие гарантии? Оказалось что для этого можно использовать механизм pipe.
Что такое pipe? Если на пальцах, то это некая «труба» между двумя процессами (producer & consumer), по которой можно передавать данные. Процесс producer пишет в write-end, а consumer читает из read-end. Если один из концов закрывается, то ОС сама закрывает pipe. Одним из примеров consumer end является System.in — тот самый, с которого мы можем читать пользовательский ввод в Java.

В случае создания дочерней JVM через ProcessBuilder, producer end для System.in в дочернем процессе принадлежит родительскому процессу. В итоге получается, что если родительский процесс завершается, то pipe закрывается и System.in.read() в дочерней JVM вернёт -1, что будет означать закрытие. Более того, System.in.read является синхронной операцией, то есть он ещё и сам будет ожидать закрытие файла (или получения данных), поэтому нам не надо делать какие-то таймауты для проверки родителя: ОС сама нам скажет, когда надо завершаться.

В итоге я реализовал и влил такое решение:
/** * Checks that parent process is alive. * * <p>We listen on {@code System.in} because this compatibility node is started by the parent JVM via * {@link ProcessBuilder}, where {@code System.in} is a pipe from the parent. When the parent process exits, * the write-end of the pipe is closed and {@code System.in.read()} returns EOF. We treat this as a signal * to stop Ignite and terminate the process.</p> */ private static void startParentPipeWatcher() { Thread thread = new Thread(() -> { try { while (System.in.read() != -1) { // No-op } } catch (IOException e) { X.println("Failed to read parent stdin pipe, stopping compatibility node: " + e); } X.println("Parent JVM stdin pipe is closed, stopping compatibility node"); try { if (ignite != null) Ignition.stop(ignite.name(), true); } catch (Throwable e) { X.println("Failed to stop compatibility node after parent pipe closure: " + e); } finally { System.exit(0); } }, "compatibility-parent-pipe-watcher"); thread.setDaemon(true); thread.start(); }
Этот watcher запускается в самом начале жизненного цикла compatibility-ноды, ещё до запуска Ignite и выполнения пользовательских настроек. Таким образом каждая JVM, поднятая в рамках compatibility-тестов, автоматически отслеживает состояние родительского процесса и корректно завершается при его аварийном завершении.
// Запускаем watcher startParentPipeWatcher(); // Делаем прочую конфигурацию final Thread watchdog = delayedDumpClasspath(); IgniteConfiguration cfg = CompatibilityTestsFacade.getConfiguration(); … // Запускаем ноду Ignite ignite = Ignition.start(cfg);
Отстрел старых orphans-JVM
Я влил исправление, но остался ещё вопрос как убить старые orphan JVM на тестовых машинах. Перезапускать целиком было неразумно: у команды разработчиков нет прямого доступа к машинам, пришлось бы просить команду DevOps внимательно просмотреть процессы на более чем 20 серверах и убить PID, что было трудоемко.
Возникла идея: да, тестовые машины являются чёрными ящиками для разработчиков и мы не можем на них действовать прямо, однако наши PR прогоняются на тестах внутри этих ящиков, а значит мы можем влиять на них изнутри с помощью тех же PR. Поэтому я навайбкодил тестовый pull request, который на этапе проверки кодового стиля собирал информацию о машине.
package org.apache.ignite.tools.checkstyle; import com.puppycrawl.tools.checkstyle.api.AbstractCheck; public class IgniteAbbrevationsRule extends AbstractCheck { public IgniteAbbrevationsRule() { dumpDiagnosticsIfEnabled(); } @Override public void visitToken(DetailAST ast) { // обычная проверка кодстайла } private static void dumpDiagnosticsIfEnabled() { System.err.println("=== Ignite system diagnostics dump start ==="); dumpProcesses(); dumpPortState(System.getProperty("os.name", "unknown")); System.err.println("=== Ignite system diagnostics dump end ==="); } }
В реальном PR внутри dumpProcesses() собирался список процессов через ProcessHandle.allProcesses(), а внутри dumpPortState() запускались системные команды вроде ps, lsof, ss и netstat.

Конкретные PID orphan JVM передал DevOps, что позволило им точечно почистить стенды. Более того, с помощью такого троянского PR можно было даже попробовать самим почистить машины, но мы решили не рисковать.
Выводы
После внедрения этого решения дочерние JVM начали корректно завершаться даже при аварийном завершении родителя. Проблема с занятыми портами исчезла, а стабильность CI заметно выросла.
Из всей этой истории я сделал несколько выводов:
При создании кода, который захватывает какие-то ресурсы, обязательно думайте об освобождении этих ресурсов и учитывайте ситуации, что код может экстренно завершиться в любой строке и не дойти до ваших блоков catch-final.
Старайтесь делегировать сложную низкоуровневую логику операционной системе или готовым механизмам, если они дают нужные гарантии.
Не оставляйте DevOps-команду один на один с такими проблемами: вы лучше знаете свой код и можете быстро подсказать, какие процессы безопасно чистить.
Как вы у себя контролируете утечки ресурсов в тестах и что делаете, когда они всплывают в CI? Делитесь примерами в комментариях.
sfzxcboy
Привет, легенда! Интересная и поучительная статья. Надо будет и свой проект проверить!