Кейсы:
- Серверные скрипты, которые вызывают и ждут другие серверные скрипты (методы API, действия БП).
- Методы API, вызывающие процессы, в которых есть несколько шагов скриптов.
- Обработчики событий, вызывающие процессы, в которых есть несколько шагов скриптов.
- Процессы, которые запускают другие процессы в скриптах.
TL;DR:
- Избегайте блокировки ожидания в скриптах.
- Разделяйте большие скрипты на более мелкие и последовательные.
- Уменьшайте количество одновременных блокирующих скриптов.
- Во всех случаях масштабирование worker может помочь.
Серверные скрипты
Ключевой частью построения гибких решений на платформе ELMA365 является возможность писать скрипты. В скриптах можно реализовать как простые операции (математические вычисления, перекладывание данных) так и более сложные взаимодействия с пользователем или интеграцию с другими системами. Скрипты могут быть серверные и клиентские. Клиентские скрипты выполняются в браузере клиента и используют ресурсы и время браузера клиента. А вот серверные, ожидаемо, выполняются на сервере платформы и используют общие ресурсы сервера.
Код:
Context.data.total = Context.data.price * Context.data.count;
В этой статье мы посмотрим только на серверные скрипты изнутри, попробуем разобраться в архитектуре их исполнения и понять какие ошибки чаще всего совершаются при разработке решений и как их избежать.
Где запускаются скрипты
Для начала давайте вспомним, где у нас в конфигурации вообще могут быть скрипты, исполняемые на сервере.
Серверные скрипты в виджетах и страницах
В этих скриптах мы чаще всего видим взаимодействие с защищенными данными или секретами, интеграции с внешними системами. Ещё их используют для оптимизации построения отчетов, чтобы не передавать на клиент слишком много данных.
Скрипты в процессах
В скриптах процессов сосредоточена большая часть бизнес логики решений. Тут и работа с приложениями, и интеграции с внешними системами, и вызовы других скриптов или процессов. Чаще всего скрипты в процессах играют ключевую роль для решения задач интеграции.
Скрипты в модулях — Методы API
Такие методы создаются в основном для интеграций и вызова из внешней системы (хотя иногда и для внутреннего использования). В реализации в скриптах можно встретить активную работу с данными (создание и поиск элементов), запуск процессов или даже вызов других внешних сервисов.
Скрипты в модулях — Действия БП
Это строительные блоки процессов, соответственно, в них можно встретить то же, что и в скриптах процессов. Плюс эти блоки используются повторно в разных процессах и могут иметь более сложную логику внутри.
Скрипты в модулях — Обработчики событий
Обработчики событий чаще всего используют для дополнительного внесения изменений в данные элементов приложения или для запуска процессов по условию изменения каких-либо данных в элементе. Иногда обработчики используются для логирования истории изменений и даже для интеграции с внешними сервисами.
Публикация и валидация скриптов
Эти операции выполняются на том же самом движке и микросервисе worker. Они, как и прочие скрипты, занимают ресурсы и проходят через очередь исполнения.
Архитектура выполнения скриптов
Теперь, когда мы прошлись по всем местам исполнения скриптов, давайте провалимся в архитектуру платформы и посмотрим как же устроено исполнение серверных скриптов.
Разберем содержимое диаграммы по порядку.
processor, integrations, widget — это вызывающие сервисы системы. Они отвечают за логику работы определенного куска конфигурации, его хранение и исполнение. В какой-то момент им требуется выполнить Скрипт и они отправляют в очередь скриптов сообщение и ожидают ответа (не всегда). Например, при выполнении блока Скрипт в процессе, сервис processor отправляет сообщение в очередь и ожидает ответа из специальной очереди “reply-to”. Если ответ не пришел за отведенное время или пришел ответ с ошибкой выполнения, то сам вызывающий сервис решает как это обработать. Например, в процессах есть настройка повторов на блоке скриптов.
Очередь script.default — эта очередь в RabbitMQ является единственной, через которую проходят все серверные скрипты. Если эта очередь растет продолжительное время, то значит что-то пошло не так. Но об этом отдельно чуть ниже.
worker (воркер) — это основной сервис платформы, который выполняет исполнение серверных скриптов. Он реализован на базе NodeJS. Более подробно его особенности мы рассмотрим ниже.
worker-gateway (гейтвей) — этот сервис помогает выполнять воркеру его работу и связывает его с остальной экосистемой платформы. Через этот сервис проходят все запросы из серверных скриптов в платформу — все поиски и сохранения элементов приложений, согласования и подписание и прочее. Однако, этот сервис не перехватывает вызовы глобального метода fetch - их выполняет сам сервис worker напрямую.
Анатомия сервиса worker
Важно понимать, что по своей архитектуре NodeJS приложение является однопоточным. Но есть возможность с помощью модуля cluster создавать отдельные экземпляры NodeJS (новые процессы в ОС).
При старте сервиса для выполнения работы над скриптами (исполнение, компиляция) создаются отдельные процессы — форки. Fork — на самом деле название метода, порождающего новый процесс, а объект, представляющий этот процесс называется Worker, но чтобы не путать с названием нашего сервиса будем использовать термин форк.
Таким образом у нас есть:
- Основной процесс, который управляет форками (создает и убивает их, следит за их состоянием, раздает им задачи).
- Процессы-форки, которые исполняют и компилируют скрипты.
Количество форков определяется конфигом сервиса (переменная окружения ELMA365_WORKER_CONCURENCY, имя параметра в конфиге appconfig.concurrency).
Работает с очередью только основной процесс. Он создает канал c prefetch count равным количеству форков. И начинает получать сообщения из очереди script.default. Т.е. из очереди одновременно может быть вытянуто сообщений не больше чем у нас есть процессов, готовых их обрабатывать.
После получения сообщения из очереди основной процесс отправляет сообщение в форк. Как только будет получено сообщение с результатом (скрипт не всегда возвращает какой-то результат, в данном случае важен сам факт завершения работы) от форка, оно отправляется как ответ в очередь сообщений.
Общение между основным процессом и форками организовано через IPC.
Исполнение скрипта
Внутри форка генерируется динамическая схема выполнения для конкретного скрипта (Application, Namespace, Global и прочее). И далее скрипт просто выполняется как блокирующая функция внутри изолированного контекста. В этот момент никакая другая активность внутри этого форка невозможна (см. выше, NodeJS работает на одном потоке).
Технически форк в NodeJS — это выделение отдельного процесса и потока в ОС. То есть при использовании нескольких форков в одном поде сервиса (по умолчанию параметр concurrency равен 8), внутри контейнера выполняется параллельно несколько процессов. Чем больше этих процессов или чем более сложную работу они выполняют, тем больше они могут мешать друг другу (ОС приходится постоянно переключаться между этими процессами для выполнения работы).
Из этого следует, что наиболее безопасным будет написание скриптов, в которых есть несколько асинхронных сетевых вызовов и небольшое кол-во синхронных вычислений внутри. Например, получение небольшого списка элементов по фильтру и вычисление среди них суммарного значения какого-то поля:
Код:
let orders = await Application.search().where(f => f.kategoria.eq(Context.data.kategoria)).size(100).all();
let summ = 0;
for (let order in orders) {
summ += order.data.amount * order.data.price;
}
Context.data.summ_orders = summ;
Очередь script.default
Сообщения в RabbitMQ попадают на exchange script, далее проходят через alternate exchange script-default и попадают в очередь script.default. Через эту единую очередь отдельными сообщениями проходят все серверные операции со скриптами:
- Исполнение скрипта
- Компиляция скрипта
- Валидация скрипта
В каждой реплике сервиса worker основной процесс подписывается на очередь script.default и распределяет выбранные сообщения по форкам. Таким образом скорость разбора очереди и пропускная способность выполнения серверных скриптов линейно масштабируется репликацией сервиса worker.
В установке On-Premises стоит отслеживать накопление сообщений в этой очереди и увеличивать ресурсы на реплики worker при увеличении количества простаивающих в очереди сообщений.
Блокирующие и неблокирующие вызовы
В скриптах часто требуется вызывать асинхронные методы. Например: fetch(), save(), search()...all(), setPermission() и другие. Все эти методы возвращают специальный объект Promise<T>, который и показывает на “асинхронность выполнения”.
Код:
let response = await
fetch(“https://my-legacy-service/api/get-client-data?clientId=”+id);
Когда в скрипте мы “ожидаем” ответа с использованием ключевого слова await, то выполнение потока скрипта останавливается на этом шаге до получения ответа ввода-вывода (обычно эти операции выполняют запросы по сети). Таким образом форк исполнения будет простаивать в ожидании всё время пока обрабатывается запрос.
Иногда можно не ожидать ответа от внешнего сервиса или конкретного метода, т.к. он не повлияет на целостность логики исполнения и данных. В таких случаях можно вызвать метод без ожидания.
Код:
fetch(“https://my-legacy-service/api/post-client-data”, {
method: ‘POST’,
body: JSON.stringify(client_data)
});
Далее мы рассмотрим разные проблемы, в том числе связанные с ожиданиями ответов и варианты их решения.
Проблемы, причины, решения
Цепная реакция, взаимоблокировка и таймауты
Нам нужно реализовать процесс отгрузки товаров. В процессе менеджер заполняет нужные поля для посылки и затем выполняется скрипт формирования доставки во внешнюю систему службы доставки. В этом скрипте через зависимость от Модуля вызывается Действие БП, внутри которого (по историческим причинам) вызывается Метод API этого же модуля. Внутри метода идет запрос во внешнюю систему и запуск процесса (где первые несколько шагов — это скрипты).
В этом примере можно заметить, что сам по себе каждый отдельный такой вызов не будет проблемой. Основная проблема — это цепочка вызовов и её влияние на движок исполнения скриптов. Если понимать принцип работы движка и единой очереди, то становится ясно, что каждый промежуточный вызов (даже внутри модуля между Действием БП и Методами API) проходит через одну общую очередь и выполняется на одном общем пуле исполнителей. Таким образом, если все исполнители заняты или в очереди накоплено очень много работы, то вся цепочка будет простаивать и в итоге отвалится по таймауту.
Другими словами, предположим у нас всего 1 реплика пода worker, и параметр concurrency настроен по умолчанию (равен 8). Мы запустили одновременно 10 процессов, в которых блок скрипта вызывает и ожидает Метод API из модуля. Наш под worker возьмет первые 8 сообщений из очереди исполнения — это будут скрипты в процессе. Отправит их в пул форков на выполнение. Далее внутри каждого такого скрипта мы вызываем метод API, который порождает новый элемент в общей очереди исполнения скриптов, при этом текущий форк блокируется и ждет ответа. Но в этот же момент очередь скриптов не может продвигаться, т.к. весь рабочий пул занят — ждет ответов. Это называется deadlock или взаимоблокировка.
Убираем вызовы между скриптами
Самый действенный способ — это оставить как можно меньше вызовов между скриптами. Например, скрипт в процессе вызывает только Действие БП а оно уже выполняет всю работу целиком. Да, это потребует копирования кода в нескольких местах и осложнит поддержку решения. Но в условиях ограничений по ресурсам это единственный выход.
(К слову, в планах платформы реализовать для скриптов файлы-зависимости, так что общую логику можно будет реализовать в одном месте.)
Масштабируем worker
В On-Premises поставке и при наличии ресурсов можно кратно масштабировать сервис worker, тем самым увеличивая количество одновременно обрабатываемых скриптов. В данном решении только один минус —оно требует выделения дополнительных вычислительных ресурсов для кластера. По сути тут добавлением ресурсов решается вопрос эффективности конечного решения.
Массовые запуски и блокировка очереди выполнения
Нам нужно реализовать обработку заявки с внешнего сайта. В каждой заявке может быть от 1 до нескольких строк заказа. Для этого в отдельном модуле мы реализовали Метод API, который создает элементы приложения для каждой строки заказа и запускает по каждой процесс. В процессе на первом же шаге стоит скрипт, который делает запрос во внешнюю систему для валидации данных заявки. Ниже приведен схематически код скриптов.
Код:
// Метод API
let orderApp = Namespace.params.fields.order.app;
for (let order_row in orders) {
let order = orderApp.create();
// заполняем заявку
await order.save();
await orderApp.processes.order_register.run({ order : order });
}
// Скрипт в процессе
let result = await fetch(‘https://my-order-service/api/check-order?order_id=’+Context.data.order.id);
И далее, при, казалось бы, небольшой нагрузке на API мы можем получать таймауты при отправке заявки из внешнего сайта.
Как мы уже знаем, очередь выполнения скриптов едина для всего кластера — и для выполнения Методов API, и для скриптов процессов.
В этом случае скрипты, которые выполняются в процессе, ожидают ответа от внешней системы. Когда их одновременно накапливается большое количество, то они занимают весь пул исполнения, а значит, очередь скриптов не разбирается. Это и приводит к тому, что скрипты начинают падать по таймауту.
Убрать блокировку
В первую очередь стоит проверить логику исполнения — можно ли реализовать цепочку взаимодействий с сервисом без блокирующего ожидания? Если внешний сервис под нашим контролем, то можно целиком убрать шаг проверки. Или сделать проверку асинхронно, за счет обратного вызова от внешнего сервиса.
Уменьшить одновременное количество скриптов
Еще одно возможное решение — это уменьшить количество запускаемых скриптов, в которых есть блокирующее ожидание. Например, запускать один процесс и скрипт на всю таблицу заказов:
Код:
// Метод API
let orderApp = Namespace.params.fields.order.app;
let order_array = [];
for (let order_row in orders) {
let order = orderApp.create();
// заполняем заявку
await order.save();
order_array.push(order);
}
await orderApp.processes.order_register.run({ orders : order_array });
// Скрипт в процессе
let results = await Promise.all( orders.map(order =>
fetch(‘https://my-order-service/api/check-order?order_id=’+order.id) );
Надо помнить, что даже в этом случае количество скриптов может превысить размер пула выполнения или время ожидания одного скрипта может превысить допустимый таймаут.
Разделить обработку на части
Можно воспользоваться тем, что мы выполняем обработку в процессе, который по своей сути хранит состояние и предназначен для надежной обработки задач разной длительности. Мы разделим общий массив заказов на части и будем выполнять скрипт последовательно в цикле процесса.
Таким образом, движок будет выполнять небольшие скрипты в рамках отведенного времени, но они будут попадать в очередь не все разом, а последовательно один после выполнения другого. Это увеличит общее время выполнения процесса, но повысит надежность и масштабируемость решения.
Масштабируем worker
Как и в предыдущем примере, мы должны понимать причину проблемы, и если нет других способов решения, то следует дать больше мощности и увеличить количество реплик сервиса worker.
Масштабирование и ресурсы
В общем случае для увеличения пропускной способности движка скриптов нужно значительно масштабировать по количеству сервисы worker и worker-gateway. Изменение внутренних лимитов и настроек этих сервисов, как правило, не требуется.
Стоит отдельно уделить внимание ресурсам, выделяемым на шину данных RabbitMQ. Очередь служит буфером, но накопление большого количества сообщений в очередях требует больше оперативной памяти и дискового пространства для надежного сохранения. При повышении нагрузок следует наблюдать за потреблением ресурсов шиной и увеличивать их по необходимости.
Далее в зависимости от используемой в скриптах функциональности и наблюдаемой нагрузки масштабировать и другие сервисы. Вы можете посмотреть какой сервис отвечает за какой функционал в справке платформы.
Также вы можете ознакомиться с расшифровкой доклада об архитектуре и масштабировании на форуме: https://community.elma365.com/ru/threads/3113/#post-4586