Всем привет, меня зовут Алена Коваленко, я Java-разработчица одной из команд направления Warehouse Management System (WMS) компании Lamoda. Наша команда занимается автоматизацией складской системы и реализацией новых фич для улучшения работы сотрудников склада. В том числе мы занимаемся техническими улучшениями системы, такими как вынесение из монолита функциональности, связанной с идентификацией товара и приемкой возвратов, в отдельный сервис — Inbound Service.

После релиза Inbound Service на проде склад заметил долгий отклик системы. В процессе исследования проблемы мы обнаружили утечку памяти, связанную с конфигурацией приложения, в том числе внутренних очередей в ActiveMQ Artemis.

В этой статье я расскажу, как мы вовремя заметили проблему и смогли ее пофиксить, предотвратив падение сервиса и дальнейшие неблагоприятные для склада последствия.

Что такое Inbound Service и зачем он нужен

Исторически сложилось, что автоматизация склада представляет из себя большой монолит, который управляет тремя основными группами бизнес-процессов: 

  • приемка товаров от поставщиков и возврат от покупателей (Inbound); 

  • хранение (Stock);

  • отгрузка товаров клиентам (Outbound). 

Когда перед нами встала задача расширить логику существующего монолита на новый склад Lamoda (поскольку для хранения и продажи большего количества товаров нынешний склад в Быково уже не сможет справиться с нагрузкой), мы приняли решение разнести основные группы процессов на отдельные сервисы. Моя команда стала ответственной за вынос процесса Inbound в сервис с одноименным названием — Inbound Service.

Inbound работает следующим образом: 

  1. Новые товары от поставщиков или возвраты от покупателей приезжают на склад. 

  2. Сотрудники разгружают грузовики с товарами.

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

  4. Если качество товара в порядке, то он перемещается на сток. Если у него все же нашли дефекты, он отправляется на станции качества, где сотрудники решают, что с ним делать дальше.

Этап проверки товара на наличие дефектов на рабочей станции
Этап проверки товара на наличие дефектов на рабочей станции

Помимо выноса в сервисы основных бизнес-процессов, мы используем отдельные приложения, которые, например, отвечают за единую точку авторизации пользователей для нескольких сервисов на основе Open Source проекта Keycloak.

Проблемы в работе нового сервиса

Логика вынесена, сервис написан, дело осталось за малым — выкатить Inbound Service в продакшн-среду склада. Но буквально через неделю после релиза склад стал сообщать о проблеме долгого отклика системы на действия пользователей. 

При исследовании дампа сервиса мы заметили большую утечку памяти, связанную с тремя основными ошибками:

  1. Лишние пользовательские сессии в Keycloak.

  2. Большой выделенный размер заголовков каждого HTTP-запроса, приходящего в Inbound Service.

  3. Лишние очереди в ActiveMQ Artemis.

В процессе вынесения логики Inbound из монолита, код переносился в новый сервис без изменений и с обязательным сохранением истории коммитов. Предполагалось, что процесс переноса будет быстрым, и проблем на проде не возникнет — ведь мы практически ничего не меняли в коде. По этой причине мы не уделяли должного внимания конфигурации сервиса. Как выяснилось, за самую большую утечку памяти отвечали лишние очереди в ActiveMQ Artemis. 

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

Конфигурация Keycloak в Spring Boot

За авторизацию пользователей в наших сервисах отвечает отдельное приложение Keycloak. Оно обеспечивает необходимый уровень безопасности, хранение пользователей, выдачу JWT-токенов и интеграцию с LDAP. В нашей архитектуре фронт посылает запрос с токеном, валидность которого проверяется на стороне сервиса. В случае протухшего токена или его отсутствия Inbound Service отвечает ошибкой 401. 

Поскольку Inbound Service реализован на основе фреймворка Spring Boot 2.4, для кастомизации конфигурации Keycloak был расширен класс KeycloakWebSecurityConfigurerAdapter. В частности, для стратегии аутентификации сессии был использован тип RegisterSessionAuthenticationStrategy:

@Bean 
@Override 
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { 
    return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
}

При анализе дампа памяти выявили, что при каждом запросе с фронта в Inbound Service создаются отдельные лишние сессии. Но при этом все необходимые для нас данные хранятся в передаваемых JWT-токенах. В чем же дело?

Причиной оказалось использование RegisterSessionAuthenticationStrategy. Для клиентов типа bearer-only, когда приложение не пытается аутентифицировать пользователей, а только проверяет валидность токена, необходимо использовать класс NullAuthenticatedSessionStrategy:

@Bean 
@Override 
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() { 
    return new NullAuthenticatedSessionStrategy(); 
}

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

Размер HTTP-заголовка

В старом монолите мы используем сервер приложений WildFly. Однако после перехода на Tomcat в нашем сервисе мы выявили проблему в конфигурации HTTP-заголовка в application.yml:

max-http-header-size: 2000000

Если присмотреться к данной конфигурации, то увидим, что максимальный размер заголовка http-запроса при такой настройке будет занимать 2 МБ (по дефолту 8 КБ). Все бы ничего, но при запуске приложения Tomcat по дефолту резервирует память для пула из 100 потоков для входящих запросов. И для каждого потока отводится по 2 МБ памяти на заголовок запроса. Итого мы имеем 200 МБ занятой памяти уже при старте приложения! 

После коррекции max-http-header-size на 64 КБ, мы получили около 6 МБ занимаемой памяти при старте Tomcat, что существенно уменьшило ее. Несмотря на это при дальнейшем отслеживании работы Inbound Service мы продолжали замечать рост Old Gen-объектов, что указало нам на проблему конфигурации очередей.

Очереди в Inbound Service

Вернемся к бизнес-процессам в Inbound Service. Во внутренней реализации приемки товаров при возникновении интересующего нас события мы используем механизм асинхронных сообщений. 

Например, у рабочей станции сотрудника есть несколько состояний, дающих пользователю возможность последовательно совершать действия: привязка паллет и контейнеров для последующей отправки товаров на другие станции, сканирование товара, изменение характеристик товара (типа хранения, срока годности), отвязка контейнеров. При изменении состояния рабочей станции или прибытии грузовика с товарами необходимо сделать запись в таблицу базы данных Inbound Service. В ней будут отражены основные сведения о бизнес-процессе, времени и действии. Такие данные необходимы для отчетности или для анализа возможных ошибок. 

Здесь используется брокер сообщений с внутренней очередью. Приложение генерирует событие, а слушатель (например, метод, аннотированный с помощью JmsListener), принимает сообщение и записывает его в БД. 

Также в работе склада участвуют много внешних сервисов (например, ERP-система Microsoft Dynamics AX), которые отправляют данные о будущей поставке на склад или для которых важно получать события о закрытии поставки для конкретного грузовика. В таком случае сообщения отправляются или читаются из внешней очереди ActiveMQ для взаимодействия склада со сторонними системами.

Схема реализации обмена склада с внешними системами
Схема реализации обмена склада с внешними системами

Apache ActiveMQ Artemis

Как уже было сказано, для нашего монолита и для новых сервисов необходим обмен сообщениями с другими внутренними или внешними системами. Этот процесс должен быть надежным и производительным, а также устойчивым к ошибкам. Все сообщения о неполадках записываются в хранилище и восстанавливаются, если произошел сбой в ПО. Такой функциональностью обладает Apache ActiveMQ Artemis, поэтому мы используем его в качестве брокера сообщений для обмена между внутренними компонентами Inbound Service.

Обмен настроен таким образом, что все сообщения являются персистентными и записываются на диск в журнал брокера. Источник события или продюсер отправляет сообщение в топик (широковещательную рассылку всем подписавшимся слушателям) или очередь (гарантированную доставку сообщения одному слушателю), и мы читаем их с помощью JmsListener. 

Поскольку в наших системах сообщение должно отправляться асинхронно сразу в несколько источников, отсюда напрашивается использование топика. Таким образом с помощью нескольких JmsListener мы можем подписаться на источник сообщения и для всех консьюмеров получать необходимые данные. 

Различия между топиком и очередью
Различия между топиком и очередью

Утечка памяти в очередях

Сообщение об изменении состояния грузовика или рабочей станции отправлялось в брокер сообщений с помощью API JMS посредством интерфейса JmsOperations:

@Service
 public class MessageSenderBean {
 	@Autowired
 	private JmsOperations jmsMessagingTemplate;
 
 	public void sendMessage(String message) {
     	    jmsMessagingTemplate.convertAndSend("topicName", message);
 	}
 }

Всю информацию по грузовику необходимо сохранять в БД Inbound Service и отправлять в очередь для внешней системы. Таким образом, было реализовано два JMS-слушателя — DBMessages и ExternalMessages:

@JmsListener(
     	destination = "topicName",
     	subscription = "DBMessages",
     	containerFactory = "jmsListenerContainerFactory")
 public void onMessage(TextMessage message) throws JMSException {
 	...
 }
 
 @JmsListener(
     	destination = "topicName",
     	subscription = "ExternalMessages",
     	containerFactory = "jmsListenerContainerFactory")
 public void onMessage(TextMessage message) throws JMSException {
 	...
 }

После релиза сервиса мы увидели, что в дампе было много объектов Artemis CoreMessage (около 300 МБ), число которых постоянно увеличивалось в процессе работы сервиса. Такое возможно, когда продюсер отправляет сообщения в топик или очередь, которые впоследствии не читаются консьюмерами. Со временем такие сообщения копились бы в памяти, что привело бы к ошибкам Out-Of-Memory и падению всего сервиса. Но почему же такое произошло? 

Прежде всего необходимо разобраться с внутренней реализацией Artemis. Понятие очереди у него отличается от классического понимания. Брокер использует модель адресации (addressing model) с тремя основными компонентами:

  • Адрес — это уникальный эндпоинт с очередями и типом роутинга.

  • Очереди — сущности, которые связаны с адресом. Для каждого адреса может быть несколько очередей. Отправленное сообщение соотносится с одним из адресов и потом рассылается по очередям в зависимости от указанного типа роутинга.

  • Тип роутинга (routing type) может быть для реализации стратегии point-to-point (Anycast) или для publish-subscribe (Multicast).

Тип роутинга Anycast служит для реализации модели очереди, когда сообщение гарантированно доставляется одному из потребителей. В случае Multicast (топик) происходит широковещательная рассылка сообщения всем подписавшимся потребителям.

Заметим, что у Artemis нет отдельной сущности топик. Такая модель реализуется с помощью создания нескольких очередей с указанием типа роутинга Multicast у адреса.

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

При подключении клиента к Multicast- или Anycast-адресу автоматически создаются очереди Artemis с адресом клиента и названием, совпадающим с subscription у листенера. Можно предварительно задать необходимые адреса и очереди в конфигурации, которые создадутся при инициализации Artemis.

Конфигурация Artemis в проекте нашего сервиса настраивается с помощью интерфейса Spring Boot ArtemisConfigurationCustomizer и выглядела следующим образом:

@Configuration
public class ArtemisConfiguration {
 
 	@Bean
 	public ArtemisConfigurationCustomizer customizer() {
     	return configuration -> {
         	AddressSettings generalSettings = new AddressSettings();
         	generalSettings.setDeadLetterAddress(new SimpleString("DLQ"));
         	generalSettings.setExpiryAddress(new SimpleString("ExpiryQueue"));
         	generalSettings.setRedeliveryDelay(500);
         	generalSettings.setMaxDeliveryAttempts(5);
         	configuration.addAddressesSetting("#", generalSettings);
 
         	AddressSettings adrSettings = new AddressSettings();
         	configuration
                 	.addAddressesSetting("topicName", adrSettings)
                 	.addQueueConfiguration(
                         	new QueueConfiguration("topicName")
                                   .setAddress("topicName")
                                   .setRoutingType(RoutingType.MULTICAST));
     	    };
 	}
 }

На основе данной конфигурации Artemis создавал три пары адресов и очередей с названиями, совпадающими с адресом:

  • адрес и очередь DLQ (Dead Letter Queue) с типом роутинга Anycast для сообщений, которые не были доставлены конечному адресату;

  • ExpiryQueue-очередь с типом Anycast (в случае, если время доставки сообщения истекло);

  • наша очередь `topicName` с типом Multicast.

Когда мы указали слушателей с subscription DBMessages и ExternalMessages, Artemis по умолчанию создавал две очереди с адресом topicName и названиями DBMessages и ExternalMessages соответственно. Как мы и хотели, сообщения дублировались и рассылались в две очереди. Однако существовала еще третья очередь, которая создавалась на основе приведенного выше конфига с адресом topicName и названием topicName! Для двух очередей были объявлены соответствующие листенеры, однако для третьей такого не было. Именно поэтому сообщения скапливались в очереди topicName без обработки продюсером. 

Варианты решения проблемы очередей

Очевидно, что простое удаление из конфига лишней очереди ни к чему бы не привело, поскольку созданная очередь уже сохранена в журнале брокера сообщений. При каждом перезапуске сервиса она восстанавливалась бы из кэша, как и непрочитанные сообщения. Полная очистка журнала Artemis грозила потерей сообщений, необходимых для обмена информацией между сервисами, и таким образом нарушила бы их работу. В итоге мы пришли к тому, что данную проблему необходимо решать поэтапно несколькими релизами Inbound Service.

Этап 1. Создали фейковый листенер с subscription равным topicName. Он считывал сообщения из лишней очереди, тем самым освобождая память, занятую ими.

Этап 2. В следующем релизе поправили конфигурацию без явного создания очереди topicName. Но все усложнялось тем, что эта очередь уже сохранилась в журнале Artemis, поэтому она будет инициализироваться каждый раз при запуске приложения. 

Поэтому мы изменили название адреса очередей и переписали листенеры для нового адреса и уникальных subscription — теперь сообщения пойдут по новому адресу. Старый адрес и связанные с ними очереди автоматически удалятся брокером, если для них нет сообщений, продюсеров и консьюмеров. Это определяется настройками auto-delete-queues и auto-delete-addresses, которые очищают автоматически созданные адреса и очереди.

Обновленная конфигурация Artemis выглядит следующим образом:

AddressSettings adrSettings = new AddressSettings();
configuration.addAddressesSetting("new_topic_name", adrSettings);

А так выглядят переписанные листенеры:

@JmsListeners(value = {
     	@JmsListener(
             	destination = "topic_name",
             	subscription = "ExternalMessages",
             	containerFactory = "jmsListenerContainerFactory"),
     	@JmsListener(
             	destination = "new_topic_name",
             	subscription = "new_topic_name" + "_ExternalListener",
             	containerFactory = "jmsListenerContainerFactory")
 })
 public void onMessage(Message message) {
 	...
 }

Пока мы оставляем листенеры для старых очередей, чтобы сообщения полностью считались из них.

Аналогично были написаны листенеры и для очереди DBMessages.

Этап 3. Теперь можно удалить листенеры старой очереди, поскольку сообщения больше в нее не идут. И вуаля! Сообщения больше не копятся в памяти Old Gen, а своевременно считываются потребителями, освобождая занятые ресурсы.


Периодически мы можем допустить небольшие ошибки при написании приложений, которые приводят к интереснейшим кейсам и загадочным Out-Of-Memory. Но своевременное выявление проблемы и нахождение решения общими усилиями команды дало нам возможность поправить работу сервиса без его отката. Это позволило складу работать без лишних простоев и нервов. Ну, ладно, нервов все же немного было :)

К сожалению, не всегда получается идеально выкатить новый сервис. Сейчас мы активно обмениваемся опытом между командами, исправляем ошибки еще до их выявления. Нам также предстоит огромная работа по открытию нового склада Lamoda. Текущие сервисы будут меняться, а новая функциональность — добавляться. Переход на микросервисную архитектуру и отлаженная работа системы дает нам возможность расширять ее и радовать склад новыми возможностями.

Комментарии (4)


  1. Quadrix
    22.12.2021 14:17
    +2

    А почему подобные проблемы только после релиза обнаружились? До релиза проводилось нагрузочное тестирование и тестирование на стабильность?


    1. kovalenchik Автор
      22.12.2021 14:39

      К сожалению, пока у нас не проводится нагрузочное тестирование перед выкаткой на прод. Но в планах хотим сделать


  1. Bugerman
    22.12.2021 15:24

    Для клиентов типа bearer-only, когда приложение не пытается аутентифицировать пользователей, а только проверяет валидность токена, необходимо использовать класс NullAuthenticatedSessionStrategy

    Так это же в документации к keycloak написано:

    You must provide a session authentication strategy bean which should be of type RegisterSessionAuthenticationStrategy for public or confidential applications and NullAuthenticatedSessionStrategy for bearer-only applications.


    1. kovalenchik Автор
      22.12.2021 15:25

      Верно) Просто обратили внимание, когда уже реализовали с RegisterSessionAuthenticationStrategy. Надо было конечно юзать NullAuthenticatedSessionStrategy