Небольшая предыстория — без рассказа про сам продукт, только про боль и как я её разгребал
Изначально это был монолитный портал на Laravel 8, где user‑фронт и admin‑фронт лежали рядом с беком в одной папке.
Проект ставился каждому клиенту на его собственный хостинг, и единого портала «для всех» не существовало, между клиентами отличался и бек, и фронт.
Кому‑то требовался функционал онлайн‑тестирования, кому‑то доработанный модуль оценивания. В итоге получался хаос из разных версий, у одного улучшили обработку заявок, у другого систему оценок, у третьего есть онлайн‑тестирование, но с отсталым модерированием.
Хуже всего это вылезало в поддержке. Приходит баг, лезешь в код и понимаешь, у другого клиента это давно починено, просто фикс так и не доехал до этой копии. Каждая правка жила в своей репе.
Поэтому, когда появилось время, я занялся этим проектом. Сразу поставил рамку, фреймворк не меняю, остаёмся на Laravel 8. Хотелось не переходить на другой язык и не переписывать весь бек целиком. План был такой: отделить всё в модули, а потом поэтапно переписывать отдельные куски.
Шаг первый: вынести фронт
Первым делом я вынес оба фронта в отдельные папки, позже в отдельные контейнеры, чтобы всё стало логичным. Ничего сложного, но бек стал беком, фронты самостоятельными сервисами, и дальше уже можно было спокойно ковырять именно бек.
Шаг второй: Идея универсального ядра
Потом я занялся беком. Хотелось сделать универсальное ядро, единое для всех, а под каждого клиента докидывать модули сверху. По сути — изобрести CMS заново, но под свою задачу.
В первой итерации всё было наивно: в app/ появилась папка Modules, куда я копировал папку модуля, и он автоматически подгружался в бек.
Любой класс под app/Modules/Quiz/… с неймспейсом App\Modules\Quiz\… подгружается сам, без отдельной записи в composer.json. Оставалось только найти провайдеры модулей и зарегистрировать их. В наивной версии я просто пробегался по папкам глобом:
foreach (glob(app_path('Modules/*/*ServiceProvider.php')) as $file) { $class = 'App\\Modules\\' . basename(dirname($file)) . '\\' . basename($file, '.php'); if (class_exists($class)) { $this->app->register($class); }}
Сам модуль в своём провайдере уже подтягивал что ему надо: конфиг, миграции, роуты, обычными средствами Laravel (mergeConfigFrom, loadMigrationsFrom, loadRoutesFrom). Но модуль был намертво вшит в образ бека, а глоб регистрировал вообще всё подряд, без разбора, включён модуль или нет.
Шаг третий: Манифесты и sidecar‑контейнеры
Папка Modules переехала из app/ в корень репозитория, и появились манифесты. Манифест описывает, что делать с модулем при старте контейнера — какой провайдер поднимать, куда класть код, какие гонять миграции и сиды, какие роуты добавлять в nginx. Некоторые модули ещё и проверяют окружение перед установкой (preflight), например, ClamAV не даст себя поставить на ВМ с 1 ГБ ОЗУ.
А ещё в манифесте появились sidecar‑контейнеры. Некоторые модули требуют для работы внешние docker‑контейнеры. Например, антивирус, в беке код лишь слушает, что появился новый файл, и шлёт его во внешний контейнер clamd, который и ведёт обработку.
// modules/virusscan/manifest.phpreturn [ 'name' => 'Антивирусная проверка', 'version' => '1.0.1', 'min_backend' => '1.0.0', 'backend' => [ 'service_provider' => App\Modules\VirusScan\VirusScanServiceProvider::class, 'module_dir' => 'VirusScan', ], 'sidecars' => [ 'clamd' => [ 'compose_service' => 'clamd', 'compose' => [ 'image' => 'clamav/clamav:stable_base', 'profiles' => ['module-virusscan', 'all-in-one'], 'healthcheck' => [ 'test' => ['CMD', 'clamdscan', '--ping'], ], 'mem_limit' => '${CLAMD_MEM:-2g}', 'cpus' => '${CLAMD_CPUS:-1.0}', // volume под базы, expose 3310, networks ], ], ], 'preflight' => ['min_free_ram_mb' => 2048],];
Как модуль добавляет свои роуты в nginx
Отдельная история — nginx. Некоторым модулям мало PHP, им нужен свой кусок конфига: свои location, проксирование на sidecar и так далее. Сделал по схеме available/enabled, как a2ensite в apache.
Конфиги модулей (modules/<key>/nginx/*.conf) при сборке образа nginx копятся в modules‑available. Лежат, но не подключены. Когда модуль включают, агент через docker exec делает симлинк из modules‑available в modules‑enabled, а nginx инклюдит только modules‑enabled/*.conf. Выключили модуль — симлинк убрали, роут исчез. Контейнер при этом не пересобирается, хватает reload.
В манифесте это описано так:
'nginx_routes' => [ 'quiz' => [ // .conf вшит в образ nginx на этапе сборки 'available' => '/etc/nginx/conf.d/modules-available/quiz.conf', // симлинк сюда создаёт агент, когда модуль включён 'enabled_path' => '/etc/nginx/conf.d/modules-enabled/quiz.conf', ],],
Грабля с регистрацией модулей
Тут я наступил на грабли. Регистрировать модули в фазе register() нельзя, база ещё не готова, запрос к таблице модулей падает, и всё тихо проваливается в fallback. Плюс провайдер, зарегистрированный внутри boot‑цикла, не получает свой boot() и роуты модуля не подхватываются.
Лечится это тем, что всю регистрацию откладываешь до полной загрузки приложения через app→booted():
public function register(): void{ $this->app->booted(function () { $this->registerEnabledModules(); });}
И ещё один важный момент, я сделал так, чтобы каждый модуль регистрировался изолированно. Раньше одно исключение в цикле снимало все модули разом. Теперь у каждого свой try‑catch, упал один, остальные работают.
foreach ($keys as $key) { try { $this->registerModule($key); } catch (\Throwable $e) { Log::error("Модуль '{$key}' не зарегистрирован: " . $e->getMessage()); }}
Совместимость модуля с ядром
Помимо min_backend в манифесте, в ядре есть константа ModuleApi::CURRENT — версия API, которую ядро даёт модулям. Перед загрузкой проверяю, что модуль не просит API новее или старше, чем ядро умеет. Не подходит — модуль пропускается, в лог падает строчка, весь бек при этом не валится.
Итого min_backend страхует от слишком старого ядра, а module_api — от модуля, который ядро уже не тянет.
А что видит фронт?
Модуль может быть выключен или вообще не куплен по лицензии, и фронт не должен показывать его кнопки и пускать на его страницы. Собирать под каждого клиента свою версию фронта я больше не хотел, поэтому фронт один на всех, а что рисовать — решается в рантайме.
При старте админка дёргает /features и получает, что включено:
// stores/app.jsasync fetchFeatures() { const { data } = await http.get('/features'); this.features = data; // { quiz: true/false, ... }}// геттерquizEnabled: (s) => s.features === null || s.features.quiz !== false,
Дальше два уровня. В меню пункт просто прячется через v‑if:
<el-sub-menu index="quiz" v-if="quizEnabled && auth.canAny('quiz.view')">
А в роутере висит гвард, даже если url модуля вбить руками, выключенный модуль тебя развернёт:
// у роутов модуля стоит meta: { quizModule: true }if (to.meta.quizModule && !app.quizEnabled) return { path: '/settings/contests' };
Получается, бек — источник правды (installed / enabled + лицензия), а фронт просто спрашивает «что показывать» и подстраивается. На публичном фронте портала то же самое.
Что модуль вообще умеет объявить
Если собрать в кучу, один манифест может притащить:
-
провайдер в бек (код, листенеры, роуты API);
-
sidecar‑контейнер (тот же clamd);
-
свой кусок конфига nginx;
-
бакеты в хранилище (у quiz — отдельный quiz‑certs под сгенерированные сертификаты);
-
preflight‑проверки железа перед установкой;
-
дефолтные env/config и сидеры с начальными данными;
-
события для системы уведомлений.
То есть модуль — это не «папка с классами», а самодостаточная вертикаль — бек, инфра, фронт‑гейтинг и данные. Ядро про конкретный модуль ничего не знает, оно только читает манифест и делает, что там написано.
Шаг четыре: Как доставлять это клиентам?
Дальше мне стало интересно, как доставлять новые модули и обновления до клиентов автоматически. Сначала я пытался научить сам бек скачивать новый образ контейнера себя же и модулей. Почему оказалось плохой думаю объяснять не стоит, бек не может надежно с fallback откатом редеплоить сам себя.
Но и идея, когда клиент лезет через SSH на свой сервер, чтобы поставить новую версию приложения мне не нравится.
Появился stack‑installer. Самодостаточный визард, которому доверили первую установку приложения, а также процесс обновления, в админке окно обновления стримит SSE из его агента, не из основного бека, агент ставит maintaince mode и следит, чтобы все новые контейнеры получили healthy статус. Хардкода в stack‑installer нет, он не заточен под конкретный проект.
В CI каждого релиза формируются три файла: два docker‑compose и setup‑manifest.yaml. Клиенту нужно один раз закинуть их на ВМ, ничего не меняя и не создавая никаких.env, сделать docker compose up ‑d и через красивый визард установить портал. Подробнее об этом расскажу в отдельной статье.
Агент, лицензии и маркетплейс
Внутри stack‑installer сидит агент, который на сервере лицензий проверяет апдейты и обновляет портал. Разделение простое. Агент обновляет ядро (меняет образы, compose, катает миграции), а модули обновляет сам бек — он лучше знает свои installed/enabled и совместимость.
При этом модули привязаны к лицензии клиента. Нет модуля в лицензии — он не приедет и не поставится. По сути, вышел целый маркетплейс.
Как модуль физически приезжает
Код модуля не лежит в образе бека. Каждый модуль — это отдельный образ module‑<key>, и тегается он своей версией из манифеста (version в manifest.php), независимо от версии ядра. Захотел обновить один модуль — обновляешь только его, ядро не трогаешь.
Когда клиент ставит модуль, агент вытаскивает код из образа module‑<key>:<версия> в общий volume modules_code, откуда его уже видит автолоад бека. Дальше бек финализирует установку — помечает модуль installed/enabled, пишет installed_version, раскладывает config модуля в настройки, гоняет сидеры и install‑хуки, перезапускает воркеры, чтобы те подхватили листенеры модуля. Всё идемпотентно, тот же путь работает и для обновления.
Отсюда и «маркетплейс»: у каждого модуля своя версия, своя совместимость с ядром и своя привязка к лицензии. Клиент получает не монолитный релиз, а набор независимых плагинов, которые можно катить по отдельности.
Что в итоге
Забавно, что проект по факту стал распределённым: 2 SPA, sidecar‑контейнеры, сервер лицензий, агент — всё крутится отдельно. Специально я к этому не шёл, просто в какой‑то момент модули и их зависимости перестали влезать в один PHP‑процесс.
А само ядро как было монолитом на Laravel 8, так и осталось.
ссылка на оригинал статьи https://habr.com/ru/articles/1066192/