Когда мне понадобилось дать приложениям RasEcosystem сетевой доступ к rac, решение на бумаге выглядело почти тривиально:
Process.Startracstdout / stderrНа первый взгляд получается небольшой сервис: принять аргументы, запустить консольную утилиту, дождаться завершения и вернуть результат.
Так появился RasGate.
Но довольно быстро выяснилось, что сам Process.Start — самая простая часть такой схемы. Нужно ограничивать количество процессов, следить за их временем жизни, читать stdout и stderr, обрабатывать отмену запросов и понимать, что делать с зависшим процессом.
А самая неприятная проблема появляется после того, как процесс уже успешно запущен: если после запуска команды сервис не смог подтвердить её результат, это ещё не означает, что команда не выполнилась.
После успешного
Process.Startмы уже не всегда можем отличить «операция не выполнилась» от «операция выполнилась, но её результат был потерян».
В итоге именно эта граница стала для меня главной проблемой в работе над RasGate.
HTTP поверх CLI — уже компромисс
Если с системой можно работать напрямую через программный API,
добавлять между ней и приложением прослойку HTTP → CLI
особого смысла нет. Это ещё один процесс, ещё один жизненный цикл
и ещё одна точка отказа.
В случае с RAS такой удобной точки входа у меня не было.
RAS — сервер администрирования кластера 1С, а rac —
штатный консольный клиент для работы с ним.
Я не хотел, чтобы RasHub сам занимался локальным запуском rac
и жизненным циклом его процессов. Поэтому вместо того, чтобы размазывать
эту механику по остальной системе, я вынес её в отдельный HTTP-сервис.
Сам по себе этот слой не избавляет от ограничений CLI.
Наоборот, теперь HTTP-сервису приходится следить за жизненным циклом
внешнего процесса и разбираться с ситуациями, которые при ручном
запуске команды обычно почти не замечаешь.
Один HTTP-запрос — один процесс
RasGate — .NET-приложение, написанное на C#, поэтому примеры ниже
будут на C#. Сами проблемы при этом не специфичны для .NET:
они возникают из-за работы с внешним процессом.
Если смотреть только на штатный сценарий, код действительно может выглядеть примерно так:
using var process = Process.Start(startInfo);
await process.WaitForExitAsync();
return process.ExitCode;
Это намеренно упрощённый пример. В реальном RasGate одного Process.Start недостаточно: приходится ограничивать параллельные запуски, отдельно передавать аргументы, одновременно читать stdout и stderr, контролировать таймаут и завершать дерево процессов.
Один HTTP-запрос здесь означает один новый процесс rac.
Если одновременно придёт много запросов, HTTP-сервер сам по себе никак не помешает создать такое же количество процессов. Поэтому RasGate ограничивает число одновременно выполняющихся команд. Если свободного слота нет, новый запрос не ждёт внутри Gate, а получает 429.
Есть свободный ресурс — команда запускается. Нет — клиент получает явный отказ. Внутреннюю очередь заданий RasGate при этом не строит.
Ещё одна деталь — передача аргументов. Я не собираю команду rac обратно в одну строку:
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
foreach (var argument in arguments)
startInfo.ArgumentList.Add(argument);
Отдельные значения остаются отдельными аргументами процесса, а между RasGate и rac не появляется shell. Не приходится самостоятельно разбираться с кавычками, пробелами и экранированием командной строки.
Вывод процесса тоже приходится обслуживать отдельно. stdout и stderr читаются одновременно. Если читать один канал, оставив второй без внимания, дочерний процесс может заполнить его буфер и заблокироваться на записи.
Позже в RasGate появился ещё и лимит объёма вывода. Без него внешний процесс может заставить сервис накапливать в памяти всё больше данных.
Но здесь возникает уже знакомая проблема: если я остановил процесс из-за слишком большого вывода, я знаю только то, что больше не могу нормально наблюдать за его выполнением. Я всё ещё не знаю, успела ли команда изменить состояние RAS.
Отменить ожидание — не значит остановить процесс
Похожая ловушка есть у CancellationToken.
await process.WaitForExitAsync(cancellationToken);
Такая запись легко создаёт ощущение, что жизненный цикл процесса уже находится под контролем. Но при отмене в первую очередь прекращается ожидание.
Сам внешний процесс живёт отдельно.
HTTP-клиент может отключиться, истечь таймаут, RasGate может начать завершение работы — а запущенный rac при этом способен продолжить выполнение.
Поэтому отмена в Gate — это начало завершения процесса, а не его конец. Нужно корректно завершить работу с каналами вывода, проверить состояние процесса, при необходимости завершить его дерево и дождаться подтверждения, что процесс действительно завершился.
Только после этого занятый им исполнительный слот можно использовать снова.
В текущей реализации RasGate идёт ещё дальше: если подтвердить завершение процесса не удалось, слот не возвращается в общий пул до перезапуска сервиса.
Это довольно консервативное поведение, но мне оно нравится больше, чем вариант, в котором Gate считает ресурс свободным, хотя предыдущий процесс теоретически всё ещё может быть жив.
Цена такого решения — постепенная потеря доступной ёмкости. Если несколько процессов не удастся подтверждённо завершить, занятыми могут оказаться все слоты, и RasGate потребуется перезапустить.
После Process.Start результат может стать неизвестным
До успешного запуска процесса со многими ошибками всё однозначно:
- аргументы не прошли проверку — команда не запускалась;
- все исполнительные слоты заняты — команда не запускалась;
racне удалось запустить — команда не была отправлена через него в RAS.
После успешного Process.Start такой уверенности уже нет.
Представим изменяющую команду:
racrac мог успеть отправить команду в RAS, а затем зависнуть. Мог закончиться таймаут. Мог отмениться HTTP-запрос. Gate мог перестать нормально получать вывод процесса.
Я могу после этого завершить локальный rac, но уже поздно отменять действие, которое он успел передать в RAS.
Таймаут не означает, что операция не выполнилась.
И обычной пары «успех / ошибка» здесь уже недостаточно.
Зачем RasGate понадобился outcome = unknown
В раннем контракте RasGate результат содержал код завершения и отдельный признак таймаута. Позже я сделал неопределённость явной частью API.
| Outcome | Что означает |
|---|---|
succeeded |
Процесс завершился, и rac вернул успешный код. |
failed |
Процесс rac завершился с ненулевым кодом, и RasGate получил этот результат. |
unknown |
RasGate больше не может подтвердить, чем закончилась внешняя операция. |
failed описывает известный результат выполнения rac.
Это само по себе не гарантирует, что операция ничего не успела изменить в RAS:
смысл такого результата уже интерпретирует RasHub.
Мне здесь нравится именно последняя формулировка.
unknown не утверждает, что произошло в RAS. Он описывает границу знания самого Gate.
Операция могла выполниться. Могла не выполниться. У RasGate больше недостаточно информации, чтобы честно выбрать между succeeded и failed.
На мой взгляд, это лучше, чем превращать любой таймаут в обычную ошибку и тем самым сообщать клиенту больше, чем сервис на самом деле знает.
Почему unknown нельзя лечить автоматическим повтором
Из неопределённого результата сразу вылезает следующая проблема: что делать клиенту?
Для сетевых ошибок очень хочется просто повторить запрос. Для изменяющих операций это может оказаться плохой идеей.
Например:
racотправляет команду создания объекта.- RAS выполняет её.
- Gate теряет результат.
- Клиент получает
unknown. - Клиент автоматически выполняет ту же команду ещё раз.
На последнем шаге можно получить второй объект, конфликт, повторное изменение или ошибку, которая вообще скроет тот факт, что первая команда была успешной.
Сам RasGate решить это не может. Для него запрос остаётся массивом аргументов rac. Gate сознательно не определяет, является команда чтением, изменением состояния или операцией, безопасной для повтора.
Это уже предметная логика, и в RasEcosystem она находится в RasHub.
Более того, неопределённость существует ещё на одном уровне. rac может нормально завершиться, Gate может получить результат и сформировать HTTP-ответ — а соединение оборвётся до того, как ответ полностью получит Hub.
Для Gate результат в этот момент известен. Для Hub — уже нет.
Поэтому при изменяющих операциях полезно различать две ситуации:
- отказ произошёл до отправки команды — операция точно не запускалась;
- запрос уже был отправлен, но результат потерян — состояние операции может быть неизвестно.
Читающий запрос во многих случаях можно повторить. Для изменения состояния такой универсальной гарантии нет.
Где я провёл границу RasGate
Когда вокруг rac уже появилась вся эта обвязка, легко было пойти дальше и начать затаскивать в Gate предметную логику.
Раз он уже знает про rac, почему бы ему ещё не выбирать команды под разные версии платформы, не разбирать вывод, не возвращать модели кластеров и баз и не решать, какие операции можно повторять?
Потому что тогда это уже будет второй RasHub.
Сейчас граница выглядит примерно так:
| RasGate | RasHub |
|---|---|
|
|
Причём это не результат позднего разделения большого компонента. Уже в первой опубликованной версии RasGate предметная логика находилась за его границей.
В итоге Gate отвечает за физическую сторону выполнения команды, а Hub — за её смысл.
Проблемы всё равно остаются
После всего этого я всё ещё не считаю схему HTTP → CLI хорошей архитектурой сама по себе.
Каждый вызов создаёт отдельный процесс. Сервис зависит от поведения внешнего исполняемого файла. Завершение локального процесса не откатывает операцию в RAS.
При этом таймаут команды пока не является абсолютно жёсткой верхней границей длительности всего HTTP-запроса. После принудительного завершения процесса текущая реализация ждёт его завершения уже без дополнительного таймаута.
Это один из сценариев, который в RasGate ещё стоит доработать.
Но основную задачу Gate это не меняет: вся эта механика остаётся внутри одного компонента.
RasHub не занимается ProcessStartInfo, каналами вывода, PID, таймаутами процессов и особенностями их завершения. Он работает с операциями над инфраструктурой.
А Gate разбирается с неприятной жизнью внешнего CLI.
Стал бы я делать так ещё раз?
Если у системы есть нормальный программный API и я могу работать с ним напрямую — скорее всего, нет.
Добавлять HTTP-сервис только ради запуска CLI обычно означает создать ещё одну границу отказа без особой пользы.
Но ситуация меняется, когда есть сторонняя или устаревшая утилита, которая уже является единственной практичной точкой входа.
Тогда небольшой сервис вокруг неё может быть вполне нормальным компромиссом. Например, если нужно дать удалённый доступ к локальной утилите, централизовать окружение её запуска, ограничить число процессов и спрятать всю работу с ними за одной границей.
Главное — не относиться к такому вызову как к обычной функции.
После успешного Process.Start появляется отдельный процесс, отдельный жизненный цикл и момент, после которого потеря ответа уже не позволяет честно утверждать, что операция не выполнилась.
