Финальная часть серии — про самое нервное в любом 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, очередями или постепенным распилом монолита — приходите в комментарии. Всегда интересно сравнить, какие места в проекте оказывались самыми болезненными.
























