- PVSM.RU - https://www.pvsm.ru -
Расскажу об одном занимательном инциденте, который заставил сильно попотеть всю нашу команду, а некоторых чуть было не довел до нервного срыва. Результатом исследований стало эффективное средство, при помощи которого любой разработчик может сильно потрепать нервы всей остальной команде. Смысл данного поста — предупредить о возможных нетривиальных ситуациях и быть к ним готовыми.

На сегодняшний день процесс разработки любого софта немыслим без использования стороннего кода, который так или иначе интегрируются с вашим. Благодаря движению open-source разработчик, однажды решивший какую-то определенную задачу, может поделиться этим решением с другими, например, опубликовав свой код в виде библиотеки. В свою очередь другим разработчикам нет необходимости заново имплементировать тот же функционал, а проще взять уже готовое решение. Однако в этом случае каждый должен осознавать, что сторонний разработчик не предоставляет абсолютно никаких гарантий качества и отсутствия ошибок. В большинстве случаев это совершенно допустимая плата за скорость разработки. Тем не менее иногда случаются неприятные ситуации, когда помимо ошибок в своем коде сталкиваешься с ошибками в чужом. В этом случае приходится открывать тикет разработчику, параллельно искать work-around, а иногда форкать библиотеку, разбираться и исправлять код самому.
При выборе библиотеки на качество могут указывать следующие косвенные параметры:
А теперь вернемся к нашей истории.
Есть у нас микросервис, который по расписанию выполняет задачи. Каждая задача состоит в том, чтобы получить много данных от сторонних сервисов, трансформировать их и залить по SFTP ввиде файла на удаленную машину.
В псевдокоде задачу можно выразить так:
public class Task implements Runnable {
static final Logger log = LoggerFactory.getLogger(Task.class);
public void run() {
log.info("Task started");
try(var remoteFile = createRemoteFile(xxx)) {
this.setState(STARTED);
for (int page = 0;;page++) {
var block = service.requestDataBlock(page);
if (block == null) break;
transformData(block);
remoteFile.writeDataBlock(block);
}
this.setState(FINISHED);
log.info("Task finished");
} catch (Exception ex) {
log.error("Task failed", ex);
this.setState(ERROR);
}
}
}
Все работало до поры до времени, пока клиент не репортнул, что некоторые задачи блокируются в state=STARTED и им приходится каждый раз перезапускать сервис. Однако в логах ничего странного не обнаруживается — как-будто задача залипает и не завершается. То есть в логах присутствует "Task started", но не видно "Task finished" или "Task failed".
Поскольку сервис коннектится к удаленным машинам, естественно первое подозрение на неправильно выставленные таймауты, которые в случае сбоя сети оставляют соединения открытыми. Второе подозрение — где-то dead lock. Не беда, просим клиента в следующий раз прислать нам thread dump процесса, чтобы посмотреть где конкретно сидит задача. Параллельно усердно шерстим код и на предмет возможных проблем.
Через пару дней ситуация повторяется, клиент присылает thread dump, который показывает, что ни один тред не занят работой — все свободны и ждут задач. Однако в логах по-прежнему есть "Task started", но не видно "Task finished" или "Task failed"! Из логики кода задачи видно, что подобное поведение возможно только, когда кидается исключение типа Throwable или Error, которое пролетает мимо обработчика Exception. Меняем catch(Exception) на catch(Throwable) и деплоим новую версию. Ситуация с точностью повторяется — в логах ничего, в thread dump-е все треды свободны.
Вот это уже интересней, ибо любое возникшее исключение обязательно так или иначе должно было отобразиться в логе. Мы решаем, что клиент не понял, когда надо снимать thread dump, и сделал это после перезапуска сервиса, а не до. После долгих препираний с клиентом выбиваем доступ к продакшн-машине и сами делаем мониторинг. Через некоторое время сами убеждаемся, что ситуация в точности та же. Вот это да!
Открываем круглый стол — предлагаем и обсуждаем различные гипотезы, и отметаем их тут же — как ни крути по логике кода задача должна либо блокироваться, либо в лог писать завершение. Была предложена очередная гипотеза, что где-то в процессинге в возникает OutOfMemoryError, который обработчик catch также не в состоянии корректно обработать по причине нехватки памяти (возникает каскад OutOfMemoryError). Чтобы убедиться в этом перезапускаем java процесс с ключом -XX:+CrashOnOutOfMemoryError а заодно -XX:+HeapDumpOnOutOfMemoryError. Первый ключ заставит java-процесс упасть при возникновении OutOfMemory и записать минимальную информацию в файле hs_err_pid, а второй — сделать полный memory dump при крэше. Важно: правильная стратегия на продакшне при возникновении OutOfMemory — это валить процесс (и возможно перезапускать), так как в случае продолжения могут возникать побочные эффекты, а дальнейшая корректная работа программы не гарантируется.
Опять не помогло. Мистика да и только: тред заходит, но никаких признаков выхода или блокировки! Уже стали обсуждаться эзотерические вещи типа где-то спрятан скрытый вызов Thread.stop(), или тред убивается средствами операционной системы, кто-то даже стал грешить на версию JDK. Единственный разумный аргумент — если возникает исключение, но в лог не записывается, то значит что-то не так с самим исключением, либо с его записью в лог. Параноидально расставляем во всех местах старый добрый System.out.println(), дополнительные try-catch и перезапускаем сервис в консольном режиме. Наутро наблюдаем очень интересную картинку:
java.lang.StackOverflowError
at java.lang.reflect.InvocationTargetException.<init>(InvocationTargetException.java:72)
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:66)
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:72)
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:72)
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:72)
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:72)
at ch.qos.logback.classic.spi.ThrowableProxy.<init>(ThrowableProxy.java:72)
...
Гипотеза оправдалась! Оказывается, свинью нам подложил Logback. В логике задачи возникло исключение, которое он не только не осилил корректно вывести в лог файл, но и что еще хуже — при этом сгенерировал еще одно исключение типа Error, которое пролетает мимо большинства обработчиков! Кто бы мог предположить, что вся проблема была в строчке:
log.error("Task failed", ex);

А все-таки что не так было с этим исключением? Беглый анализ конструктора класса ThrowableProxy из Logback показал, что проблема связана с suppressed exceptions, где конструктор рекурсивно вызывает сам себя бесконечное число раз:
suppressed = new ThrowableProxy[throwableSuppressed.length];
for (int i = 0; i < throwableSuppressed.length; i++) {
this.suppressed[i] = new ThrowableProxy(throwableSuppressed[i]);
this.suppressed[i].commonFrames =
ThrowableProxyUtil.findNumberOfCommonFrames(throwableSuppressed[i].getStackTrace(),
stackTraceElementProxyArray);
}
Такое поведение возможно только, если исключение в suppressed exceptions содержит циклическую ссылку на само себя. Проверяем при помощи теста:
@Test
public void testLogback() throws Exception {
Logger log = LoggerFactory.getLogger(TestLogback.class);
// Для прямой ссылки java выкинет
// java.lang.IllegalArgumentException: Self-suppression not permitted
//ex.addSuppressed(ex);
// поэтому ссылаемся транзитивно
Exception ex = new Exception("Test exception");
Exception ex1 = new Exception("Test exception1");
ex.addSuppressed(ex1);
ex1.addSuppressed(ex);
log.error("Exception", ex);
}
Так и есть — вылетает с ошибкой StackOverflowError! То есть самую популярную библиотеку для логгинга в экосистеме Java, которая используется в огромном количестве проектов (почти 19000 maven-артефактов) любой разработчик может скомпрометировать всего четырьмя строчками, причем так, что анализ причины будет подобен детективу с закрученным сюжетом. Однако в нашем случае Logback скомпрометировали авторы еще одной сторонней библиотеки, которую мы использовали для заливки файла по SFTP — Apache VFS. Эта библиотека при ошибке закрытия файла на удаленной файловой системе генерировала IOException с цепочкой suppressed, в которой как раз содержалась циклическая ссылка на изначальный IOException. Но это уже другая история.
А что там у Logback в багтреккере? А вот собственно тикет, открытый еще с 2014 года:
https://jira.qos.ch/browse/LOGBACK-1027 [1]
и его дубликат с 2019:
https://jira.qos.ch/browse/LOGBACK-1454 [2]
Данная баг фича все еще присутствует в последней версии библиотеки.
Хорошо, а как ведет себя Java в случае циклических ссылок? Как видно, если сделать ex.printStackTrace(), то все выходит корректно, циклическая ссылка заменяется на CIRCULAR REFERENCE:
java.io.IOException: Test exception
at org.example.test.TestLogback.lambda$testJavaOutput$1(TestLogback.java:43)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
Suppressed: java.lang.Exception: Test exception1
at org.example.test.TestLogback.lambda$testJavaOutput$1(TestLogback.java:44)
... 5 more
[CIRCULAR REFERENCE:java.lang.Exception: Test exception]
Абсолютно аналогичный вывод дает java.util.logging.
Log4j не детектит цикличность, однако корректно выводит только два уровня для suppressed exception.
14:15:22.154 [main] ERROR org.example.test.TestLogback - Test
java.lang.Exception: Test exception
at org.example.test.TestLogback.testLog4j(TestLogback.java:61) [test-classes/:?]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_251]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[?:1.8.0_251]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_251]
at java.lang.reflect.Method.invoke(Method.java:498) ~[?:1.8.0_251]
Suppressed: java.lang.Exception: Test exception1
at org.example.test.TestLogback.testLog4j(TestLogback.java:62) [test-classes/:?]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_251]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[?:1.8.0_251]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_251]
at java.lang.reflect.Method.invoke(Method.java:498) ~[?:1.8.0_251]
Suppressed: java.lang.Exception: Test exception
at org.example.test.TestLogback.testLog4j(TestLogback.java:61) [test-classes/:?]
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_251]
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[?:1.8.0_251]
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[?:1.8.0_251]
at java.lang.reflect.Method.invoke(Method.java:498) ~[?:1.8.0_251]
Данная статья никоим образом не ставит целью очернить Logback — это прекрасная библиотека с большим набором возможностей. Все вышесказанное было скорей примером, чтобы подчеркнуть следующие банальные истины при использовании стороннего кода:
Автор: Антон Куранов
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/358069
Ссылки в тексте:
[1] https://jira.qos.ch/browse/LOGBACK-1027: https://jira.qos.ch/browse/LOGBACK-1027
[2] https://jira.qos.ch/browse/LOGBACK-1454: https://jira.qos.ch/browse/LOGBACK-1454
[3] Источник: https://habr.com/ru/post/524130/?utm_source=habrahabr&utm_medium=rss&utm_campaign=524130
Нажмите здесь для печати.