Финальная часть серии — про самое нервное в любом ecommerce-проекте: как включать новую архитектуру по частям, не устраивать «большой релиз» и не останавливать продажи.
К этому моменту у нас уже есть SSO, события, наблюдаемость, быстрый каталог, корзина, цены, checkout, интеграции, Gateway и SDK. Теперь начинается самая чувствительная часть — включать все это в продакшен без большого релиза или остановки продаж.
Сложность — в процессе. Один модуль уже готов, второй еще нет, часть трафика ходит по старому пути, часть — по новому… Поэтому я сделала практическую схему постепенного включения: фича-флаги, канареечный трафик, двойное чтение, shadow-режим и критерии готовности.
Фича-флаги и сегменты трафика
Все новые сценарии включаем только через фича-флаги и поэтапно.
Обычно хватает трех уровней управления:
глобальный флаг — включен ли модуль вообще;
процент трафика — 1%, 10%, 50%, 100%;
сегменты и whitelist — QA, сотрудники, тестовые пользователи или отдельные клиенты.
Таблица флагов в Laravel:
Скрытый текст
<?php use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void { Schema::create('feature_flags', function (Blueprint $t): void { $t->string('name', 64)->primary(); // catalog_read, cart_api, pricing_api, orders_fastpath ... $t->boolean('enabled')->default(false); $t->unsignedTinyInteger('percent')->default(0); // 0..100 $t->json('whitelist')->nullable(); // [user_id...] $t->json('payload')->nullable(); // произвольные параметры $t->timestamps(); }); } };
Простой сервис флагов:
<?php namespace App\Flags; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; final class Flags { public function isOn(string $name, ?int $userId, ?string $stickyKey = null): bool { $flag = Cache::remember("ff:$name", 5, fn () => DB::table('feature_flags')->find($name)); if (! $flag || ! $flag->enabled) { return false; } $wl = $flag->whitelist ? \json_decode((string) $flag->whitelist, true) : []; if ($userId && \in_array($userId, $wl, true)) { return true; } $percent = (int) $flag->percent; if ($percent <= 0) { return false; } // "липкое" распределение: один и тот же ключ всегда попадает одинаково $key = $stickyKey ?: (string) $userId ?: request()->ip(); $hash = \crc32($name . '|' . $key) % 100; return $hash < $percent; } }
В Gateway и доменных API вызываем app(Flags::class)->isOn('cart_api', $userId, $cookieCgid).
Расщепление трафика на уровне Nginx
Иногда удобнее отправлять часть трафика на новый сервис. Например, для чтения каталога:
Скрытый текст
map $cookie_ab_catalog $ab_catalog { default "A"; ~^B$ "B"; } # Проставляем cookie 10 % пользователям (один раз), остальным - A map $request_uri $catalog_bucket { default 0; ~^/catalog 1; } server { if ($catalog_bucket = 1) { add_header Set-Cookie "ab_catalog=$ab_catalog; Path=/; Max-Age=2592000; SameSite=Lax" always; } location ~ ^/api/v1/catalog/search { if ($ab_catalog = B) { proxy_pass http://laravel_catalog; # новый путь } proxy_pass http://old_bitrix_catalog; # старый путь } }
Значение cookie ab_catalog=B можно выдавать на бэке через флаг на 10 % пользователей.
Техника двойного чтения и сравнение
Перед переключением чтения на новый сервис включаем shadow-read. Старый путь продолжает отвечать пользователю как раньше, а новый сервис вызывается параллельно в фоне.
Дальше сравниваем ответы: состав данных, цены, остатки, время ответа и ошибки. Так новый путь можно проверить на реальном трафике еще до того, как он начнет обслуживать пользователей напрямую. Пример middleware в Битрикс на PHP:
Скрытый текст
<?php use Bitrix\Main\Web\HttpClient; use Bitrix\Main\Diag\Debug; function shadow_compare_catalog(array $params, array $oldResult): void { // включаем по флагу if (!get_flag('shadow_catalog')) { return; } $http = new HttpClient(['socketTimeout' => 1, 'streamTimeout' => 1]); $http->query('GET', 'https://api.example.ru/api/v1/catalog/search?' . http_build_query($params)); $new = @\Bitrix\Main\Web\Json::decode((string) $http->getResult()); // сравниваем топ-5 ids и счетчик $oldIds = array_slice(array_column($oldResult['items'] ?? [], 'ID'), 0, 5); $newIds = array_slice(array_map(static fn($h) => (int)($h['_source']['id'] ?? 0), (array)($new['list'] ?? [])), 0, 5); if ($oldResult['total'] != ($new['total'] ?? null) || $oldIds !== $newIds) { Debug::writeToFile([ 'params' => $params, 'old_total' => $oldResult['total'] ?? null, 'new_total' => $new['total'] ?? null, 'old_top' => $oldIds, 'new_top' => $newIds, ], 'catalog_compare', '/local/logs/compat.log'); } }
Переключатель в одну строку
У каждого нового модуля должен быть простой и быстрый способ отката. Самый удобный вариант — держать такой переключатель прямо на входе Gateway:
Скрытый текст
<?php namespace App\Http\Middleware; use App\Flags\Flags; use Closure; use Illuminate\Http\Request; final class KillSwitch { public function handle(Request $r, Closure $next) { if (env('KILL_CATALOG', false)) { return response()->json(['ok' => false, 'error' => 'temporarily_disabled'], 503); } return $next($r); } }
В критический момент достаточно переключить Gateway или Nginx обратно на старый путь. Новый сервис при этом может отвечать 503, чтобы фронт сразу получил понятную ошибку, а не висел в бесконечных retry и таймаутах.
Порядок включения модулей и критерии готовности
Б2. Ускорение админки
Включаем сразу для роли «Контент».
Следим за несколькими вещами:
P95открытия формы товара < 1.5 s;Сохранить < 400 ms;
меньше JS-ошибок в админке.
Если что-то пошло не так — просто выключаем модуль в Битрикс. Gateway не трогаем.
Б1. Каталог и OpenSearch
Запускаем поэтапно:
сначала включаем двойное чтение и сравниваем выдачу, агрегации и пагинацию;
потом переводим 5% каталожного трафика;
дальше 50% и 100%.
Проверяем:
TTFBлистинга на cache hit < 150 ms;cache miss < 400 ms;
расхождение total не больше ±1%.
Откат простой:
возвращаем старый маршрут в Nginx;
инвалидируем кэш.
В1. Адаптер корзины
Сначала:
читаем корзину через API;
кнопки «Добавить» пока остаются старыми.
Потом:
переводим
addиpatchна API для части пользователей;Битрикс продолжает материализовать корзину для скидок.
Смотрим:
конфликты ревизий < 2%;
p95
add/patch< 80 ms.
Для отката фронт просто возвращается к старым вызовам. Redis-корзины доживают свой TTL и не мешают.
В2. Цены и остатки
Этапы здесь такие:
двойное чтение и сравнение с
b_*;логирование расхождений в Sentry;
переход карточки товара на
/api/pricingи/api/inventory;перевод каталога на batch-запросы.
Критерии:
p95
/pricing< 120 ms;cache hit > 80%;
лаг
price.changedиstock.changed< 30 s.
Откат — возврат на старое чтение.
В3. Быстрый путь заказов
Сначала включаем новый API для 1% заказов с материализацией в Битрикс.
Дальше идем постепенно:
10%;
50%;
100%.
Платеж можно оставить на старом пути и вынести позже.
Проверяем:
p95
POST /api/orders< 300 ms;материализация p95 < 5 s;
доля ошибок < 0.1%.
Если нужен откат:
выключаем materialize-job;
фронт возвращается на старую ручку Битрикс.
Г1. Шлюз интеграций
Подключаем по частям:
сначала вебхукии платежек;
потом создание платежей;
дальше доставки и ERP.
Смотрим на:
DLQ < 0.5%;
среднее число попыток < 1.5;
очереди без лагов > 5 минут.
При откате возвращаем старые прямые вызовы. Задачи в DLQ не теряются.
Г2. Уведомления
Сначала переносим только системные письма:
заказ создан;
оплата прошла.
Потом подключаем SMS и push.
Критерии:
p95 рендера < 50 ms;
p95 отправки < 3 s;
bounce-rate остается в норме.
При откате Битрикс снова использует свои шаблоны, а сервис уведомлений можно оставить в standby.
Чек-листы выпусков
Для каждого этапа держим отдельный чек-лист. Например, перед переключением каталога на новое чтение проверяем:
backfill витрин
svc_catalog_*,svc_price_snapshot,svc_inventory_snapshotзавершен;индекс OpenSearch собран полностью, поиск без дыр, размер индекса в допустимых границах;
события
catalog.element.updated,price.changed,stock.changedприходят стабильно, лаг < 10 s;shadow-read показывает ≥ 99% совпадений по total и топам, все расхождения объяснимы;
Nginx настроен на расщепление трафика, фича-флаги заведены;
алерты на p95 TTFB, долю 5xx, лаг индексации и cache hit работают;
runbook отката подготовлен: возврат старого пути и очистка кэшей описаны заранее.
Пример: маршрутизация в коде гейтвея
Для корзины включаем новый путь по флагу:
<?php use App\Flags\Flags; use Illuminate\Support\Facades\Route; Route::get('/page/cart', static function (\Illuminate\Http\Request $r) { $uid = $r->user()?->getAuthIdentifier() ? (int) $r->user()->getAuthIdentifier() : null; $gid = (string) $r->cookie('cgid', ''); if (app(Flags::class)->isOn('cart_api', $uid, $gid)) { return app(\App\Http\Controllers\Gateway\CartController::class)->show($r); } // старый путь (до миграции фронта) return redirect()->away('https://bitrix.example.ru/personal/cart/'); });
Финальные советы по операционке
После нескольких таких запусков понятно, что основная сложность обычно не в Laravel, Redis или OpenSearch. Самое сложное — научиться менять продакшен постепенно, без героических ночных релизов.
Несколько вещей, которые потом экономят очень много времени и нервов:
не пытайтесь переносить все сразу — каждый модуль должен приносить пользу отдельно;
заранее договоритесь с бизнесом про SLO и показывайте метрики «до» и «после» каждого этапа;
любой новый путь сначала должен уметь нормально откатываться и только потом — принимать production-трафик;
решения принимаем по метрикам;
для каждого модуля держим короткие runbook: как выключить, как вернуть старый путь, как проверить состояние;
в момент инцидента счет обычно идет на минуты, поэтому инструкции должны читаться быстрее, чем открывается IDE.
Что отдаем на проекте: артефакты, структура, инфраструктура
Вот базовый пакет поставки, с которым команда может поднять окружение, проверить контракты и подключать модули постепенно, а не одним большим релизом.
1. Контракты API
AsyncAPI 2.6 для событий: catalog.element.updated, price.changed, stock.changed, order.created|updated, integration.events.
Скрытый текст
Примеры фрагментов:
# openapi/cart.yaml openapi: 3.1.0 info: { title: Cart API, version: 1.0.0 } paths: /api/v1/cart: get: responses: '200': description: Snapshot content: { application/json: { schema: { $ref: '#/components/schemas/Cart' } } } post: requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/AddLine' } } } } responses: { '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Cart' } } } } } components: schemas: Cart: type: object properties: owner: { type: string } revision: { type: integer } currency: { type: string } lines: type: array items: type: object properties: lineId: { type: string } sku: { type: string } qty: { type: number } attrs: { type: object, additionalProperties: { type: string } } AddLine: type: object required: [sku, qty] properties: sku: { type: string } qty: { type: number, minimum: 0.001 } attrs: { type: object, additionalProperties: { type: string } } # asyncapi/events.yaml asyncapi: 2.6.0 info: { title: Commerce Events, version: 1.0.0 } channels: price.changed: publish: message: name: price.changed payload: type: object required: [product_id, ptype, amount, currency, version, updated_at] properties: product_id: { type: integer } ptype: { type: string } segment: { type: string, default: default } amount: { type: number } currency: { type: string } version: { type: integer } updated_at: { type: string, format: date-time }
2. Скелеты пакетов и модулей
Laravel-пакеты:
auth-bridge,outbox-streams,catalog-read,cart,pricing-inventory,orders,integration-hub,notifications,gateway.Bitrix-модули:
project.core(outbox,fast-order),project.admin-accelerator.SDK:
@acme/commerce-sdk-core,@acme/commerce-sdk-react,@acme/commerce-sdk-vue.
Минимальные composer.json и psr-4 неймспейсы, phpstan.neon, php-cs-fixer.php по PSR.
// php-cs-fixer.php <?php $rules = [ '@PSR12' => true, 'array_syntax' => ['syntax' => 'short'], 'ordered_imports' => true, 'no_unused_imports' => true, ]; return (new PhpCsFixer\Config()) ->setRules($rules) ->setRiskyAllowed(true) ->setFinder( PhpCsFixer\Finder::create()->in(['src','app']) );
3. Monorepo-структура
Скрытый текст
repo/ ├─ services/ │ ├─ bitrix/ # шаблон проекта Битрикс + local/modules/project.* │ ├─ gateway/ # Laravel Gateway │ ├─ catalog-read/ # OpenSearch │ ├─ cart/ │ ├─ pricing-inventory/ │ ├─ orders/ │ ├─ integration-hub/ │ └─ notifications/ ├─ sdk/ │ ├─ core/ │ ├─ react/ │ └─ vue/ ├─ contracts/ │ ├─ openapi/ │ └─ asyncapi/ ├─ ops/ │ ├─ docker/ │ ├─ compose/ │ ├─ k8s/ │ └─ terraform/ ├─ observability/ │ ├─ grafana-dashboards/ │ └─ prom-rules/ ├─ docs/ │ ├─ runbooks/ │ ├─ rollout-checklists/ │ └─ decisions/ # ADR └─ .github/workflows/
4. Docker Compose для локалки
С профильным набором: Nginx, Bitrix (php-fpm), Laravel-сервисы, MySQL, Redis, OpenSearch, Mailhog, Jaeger, Prometheus, Grafana.
Скрытый текст
# ops/compose/docker-compose.yml version: "3.9" services: nginx: image: nginx:1.27 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ../services/bitrix:/var/www/bitrix - ../services/gateway:/var/www/gateway ports: ["8080:80"] depends_on: [bitrix, gateway] bitrix: build: ../services/bitrix/.docker/php-fpm environment: - DB_HOST=db volumes: [ "../services/bitrix:/var/www/bitrix" ] depends_on: [db, redis] gateway: build: ../services/gateway environment: - DB_HOST=db - REDIS_HOST=redis - OPENSEARCH_HOST=opensearch volumes: [ "../services/gateway:/var/www/gateway" ] depends_on: [db, redis, opensearch] db: image: mysql:8.4 environment: - MYSQL_ROOT_PASSWORD=root - MYSQL_DATABASE=app ports: ["3306:3306"] redis: image: redis:7.2 ports: ["6379:6379"] opensearch: image: opensearchproject/opensearch:2.15.0 environment: { discovery.type: single-node, plugins.security.disabled: "true" } ports: ["9200:9200"] mailhog: image: mailhog/mailhog ports: ["8025:8025"] jaeger: image: jaegertracing/all-in-one:1.57 ports: ["16686:16686"] prometheus: image: prom/prometheus grafana: image: grafana/grafana-oss ports: ["3000:3000"]
Makefile для удобства:
up: ; docker compose -f ops/compose/docker-compose.yml up -d down: ; docker compose -f ops/compose/docker-compose.yml down -v logs: ; docker compose -f ops/compose/docker-compose.yml logs -f --tail=200 migrate: ; docker compose exec gateway php artisan migrate seed: ; docker compose exec gateway php artisan db:seed
5. Наблюдаемость
Для всех сервисов подготовлены экспортеры метрик и готовые Grafana-дашборды:
latency P50/P95;
доля 5xx и 429;
лаг Redis Streams;
cache hit;
длины очередей;
DLQ.
Для PHP-сервисов есть готовый OpenTelemetry-конфиг:
Laravel;
Битрикс;
входящие и исходящие HTTP-запросы;
Redis;
MySQL.
Трейсы можно сразу смотреть в Jaeger через готовый дашборд.
Отдельно подготовлены Prometheus-алерты с порогами из KPI и SLO.
6. CI/CD
GitHub Actions (или GitLab CI) с этапами: линтеры, статика, тесты, сборка образов, деплой.
Скрытый текст
# .github/workflows/ci.yml name: ci on: [push, pull_request] jobs: php: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: { php-version: '8.3', coverage: none, tools: composer, extensions: redis } - run: composer install -q --no-ansi --no-interaction --no-progress --prefer-dist working-directory: services/gateway - run: vendor/bin/php-cs-fixer fix --dry-run --diff working-directory: services/gateway - run: vendor/bin/phpstan analyse --no-progress working-directory: services/gateway - run: vendor/bin/pest working-directory: services/gateway docker: needs: php runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: { username: ${{ secrets.DOCKER_USER }}, password: ${{ secrets.DOCKER_PASS }} } - name: build gateway run: docker build -t ${{ secrets.REGISTRY }}/gateway:${{ github.sha }} services/gateway Pre-commit в репозитории: # .pre-commit-config.yaml repos: - repo: https://github.com/PHP-CS-Fixer/PHP-CS-Fixer rev: v3.64.0 hooks: [{ id: php-cs-fixer, args: ["--config=php-cs-fixer.php","--dry-run","--diff"] }] - repo: https://github.com/phpstan/phpstan rev: 1.11.8 hooks: [{ id: phpstan, args: ["analyse","--no-progress"] }]
7. Сид данных и фича-флаги по умолчанию
Сидер, который создает типы цен, тестовые товары, индексы OpenSearch, и включает минимальные флаги в 0%:
<?php namespace Database\Seeders; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\DB; final class FeatureFlagsSeeder extends Seeder { public function run(): void { foreach (['catalog_read','cart_api','pricing_api','orders_fastpath'] as $name) { DB::table('feature_flags')->updateOrInsert( ['name' => $name], ['enabled' => false, 'percent' => 0, 'updated_at' => now(), 'created_at' => now()] ); } } }
8. Документация и runbook
В поставку входят базовые инструкции для команды и эксплуатации:
«Как поднять локальное окружение» — одна страница с
make up,migrate, seedи проверкой сервисов;«Как выкатывать по частям» — чек-листы по rollout, shadow-read, канареечному трафику и переключению модулей;
«Откат» — пошаговые инструкции по kill-switch, возврату старых маршрутов и очистке кэшей;
-
ADR-документы по ключевым архитектурным решениям:
Outbox;
Redis Streams;
OpenSearch;
Gateway и SDK;
быстрый checkout и materialize-flow.
9. Демо-сценарии для QA
Для QA и интеграционного тестирования подготовлены:
коллекции HTTP-запросов (HTTP YAML, Insomnia) со всеми ручками и примерами payload;
-
фикстуры для песочницы:
1000 товаров;
10 типов цен;
2 склада;
100 тестовых заказов;
-
сценарии нагрузки на k6 или Artillery:
листинги;
карточка товара;
добавление в корзину;
расчет цен;
создание заказа.
10. Примеры UI
В комплекте идут минимальные примеры фронта из раздела про Gateway и SDK:
ProductPage.vue;
CartPage.vue.
Отдельно — тестовый SSR-шаблон с интеграцией SDK на серверном рендеринге.
Завершение
Если вкратце, мы не переписываем Битрикс с нуля и не устраиваем многолетнюю миграцию ради модной архитектуры. Мы постепенно выносим из монолита все, что сильнее всего упирается в нагрузку, масштабирование и скорость разработки.
Каталог и фильтры переезжают в OpenSearch. Цены и остатки обслуживаются отдельным API. Корзина становится общей для всех фронтов. Заказ принимается быстро, а тяжелая обработка уходит в очереди и события. Интеграции, уведомления и внешние вызовы работают отдельно и больше не тормозят checkout.
При этом Битрикс остается админкой, контентным слоем и точкой совместимости со старым контуром.
В центре всей схемы — единая авторизация, наблюдаемость, Gateway и понятные контракты между сервисами. За счет этого систему можно развивать по частям и спокойно выкатывать изменения.
Наши итоги:
Блок |
Что получили |
Архитектура |
Битрикс продолжает работать как админка и контентный слой, а тяжелые сценарии постепенно выносятся в отдельные сервисы без переписывания всей системы |
Контракты |
REST через OpenAPI и события через AsyncAPI позволяют внедрять модули по одному, без большого релиза |
Владение данными |
Запись остается у владельца схемы, чтение уходит в |
Наблюдаемость |
|
Каталог |
TTFB листинга: cache hit < 150 ms, miss < 400 ms |
Админка |
P95 открытия формы < 1.5 s, сохранение < 400 ms |
Заказы |
POST |
Очереди |
лаг Redis Streams под нагрузкой < 30 s, DLQ < 0.5% |
Риски |
Расхождения гасим через shadow-read, события, TTL, ретраи, DLQ и runbook’и |
Выкатка |
Все включается через фича-флаги: 1% → 10% → 50% → 100% |
Откат |
У каждого модуля есть kill-switch и короткий сценарий возврата |
Инфраструктура |
Docker Compose, OpenAPI/AsyncAPI, SDK, Grafana, Prometheus, CI и готовые пакеты |
Спасибо, что дочитали. Это была финальная часть нашей серии про Битрикс, Laravel, очереди, OpenSearch, Gateway, checkout и попытку не сойти с ума посреди ecommerce-проекта с историей в десять лет.
Хеппи-энд тут, наверное, в том, что не пришлось ничего переписывать с нуля. Битрикс остался жить своей жизнью: админка, контент, менеджеры, старые интеграции. А все тяжелое, чувствительное к нагрузке и скорости разработки постепенно уехало в отдельные сервисы и нормальные контракты.
В статьях специально много практики. Надеюсь, что-то из этого пригодится вам целиком или хотя бы отдельными кусками. А если у вас был похожий опыт с Битрикс, Laravel, OpenSearch, очередями или постепенным распилом монолита — приходите в комментарии. Всегда интересно сравнить, какие места в проекте оказывались самыми болезненными.