Паттерн Strangler Fig на практике: постепенный перевод PHP-монолита на микросервисы через REST API
Введение: что такое Strangler Fig и почему это лучший подход для PHP-проектов
Паттерн Strangler Fig был описан Мартином Фаулером в 2004 году и назван в честь тропического растения, которое постепенно обвивает дерево-хозяина и в конечном счёте заменяет его. Применительно к архитектуре программного обеспечения суть паттерна проста: вместо того чтобы переписывать систему с нуля, вы постепенно выносите отдельные части функциональности в новые сервисы, продолжая при этом поддерживать работоспособность монолита.
Для PHP-проектов, особенно построенных на Laravel или Symfony, этот подход особенно актуален. Большинство крупных PHP-приложений накапливали функциональность годами, и их кодовая база стала трудно поддерживаемой. Полное переписывание — это классическая ловушка «второй системы»: огромные риски, заморозка новых фич, непредсказуемые сроки. Strangler Fig позволяет двигаться итерационно, сохраняя бизнес-ценность на каждом шаге.
Ключевое преимущество паттерна — возможность запускать монолит и новые микросервисы параллельно через единую точку входа, постепенно перенаправляя трафик. REST API выступает здесь естественным контрактом взаимодействия, а API Gateway — центральным элементом оркестрации.
Анализ монолита: как определить границы будущих сервисов
Прежде чем выделять первый сервис, необходимо провести domain mapping — анализ предметной области и выявление естественных границ между модулями. В контексте Laravel-монолита это означает изучение структуры моделей, контроллеров и зависимостей между ними.
Инструменты анализа
- Анализ связей базы данных — таблицы с минимальным количеством внешних ключей, ведущих в другие домены, — первые кандидаты на выделение.
- Тепловая карта изменений — модули, которые меняются независимо друг от друга, легче изолировать.
- Метрика coupling/cohesion — высокое внутреннее сцепление (cohesion) и низкая внешняя связанность (coupling) указывают на хорошую границу будущего сервиса.
- Event Storming — совместная сессия с командой для выявления доменных событий и агрегатов.
Практические признаки хорошей границы сервиса
- Модуль имеет собственный жизненный цикл данных, не разделяя транзакции с другими модулями.
- Команды, работающие с модулем, могут деплоить его независимо.
- REST API для модуля может быть описан без раскрытия внутренней структуры других доменов.
- Модуль имеет чётко выраженного «владельца» в команде.
Типичные первые кандидаты в PHP-проектах: аутентификация и управление сессиями, уведомления (email, push, SMS), файловый сторидж, биллинг, поиск. Эти модули часто имеют высокий deployment frequency и минимальные зависимости от бизнес-ядра.
Роль API Gateway: маршрутизация между монолитом и новыми сервисами
API Gateway — это сердце паттерна Strangler Fig. Именно он принимает все входящие запросы и решает, направить ли их в старый PHP-монолит или в новый микросервис. Это позволяет клиентам (фронтенду, мобильным приложениям) работать с единым эндпоинтом, не зная о внутренних изменениях.
Варианты реализации API Gateway
- Nginx с динамической конфигурацией — минималистичный вариант для старта, подходит для простых правил маршрутизации по префиксу пути.
- Kong Gateway — мощное решение с поддержкой плагинов, rate limiting, JWT-валидации и детальным логированием.
- AWS API Gateway / GCP Cloud Endpoints — облачные решения с встроенной масштабируемостью.
- Traefik — легковесный reverse proxy с нативной интеграцией в Docker и Kubernetes, удобен при контейнеризации сервисов.
Базовый принцип конфигурации: для каждого выделяемого сервиса добавляется правило маршрутизации, которое перехватывает запросы к определённому пути и направляет их в новый сервис. Остальные запросы по умолчанию уходят в монолит.
# Пример конфигурации Nginx для паттерна Strangler Fig
upstream monolith {
server php-monolith:80;
}
upstream auth_service {
server auth-go-service:8080;
}
upstream notification_service {
server notification-service:8081;
}
server {
listen 80;
# Новый Auth-сервис перехватывает запросы
location /api/v1/auth/ {
proxy_pass http://auth_service;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Сервис уведомлений
location /api/v1/notifications/ {
proxy_pass http://notification_service;
proxy_set_header Host $host;
}
# Всё остальное — в PHP-монолит
location / {
proxy_pass http://monolith;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Критически важно реализовать circuit breaker на уровне Gateway: если новый сервис недоступен, запросы должны автоматически fallback-аться на монолит. Это обеспечивает нулевую деградацию сервиса при проблемах в процессе миграции.
Пошаговая реализация: выделение первого сервиса
Успешная декомпозиция монолита требует строгой последовательности действий. Импровизация здесь опасна — каждый шаг должен быть обратимым.
Шаг 1: Описание публичного API будущего сервиса
До написания кода нового сервиса необходимо описать его REST API в формате OpenAPI 3.0. Это создаёт контракт, которому должны следовать как новый сервис, так и потребители API. Контракт фиксирует эндпоинты, схемы запросов и ответов, коды ошибок.
Шаг 2: Создание нового сервиса
Новый сервис реализует описанный API. На этом этапе он может внутренне обращаться к базе данных монолита (если разделение БД ещё не произошло) через выделенный read-only replica или отдельного пользователя с ограниченными правами.
Шаг 3: Параллельная работа (Shadow Mode)
Перед переключением трафика запустите новый сервис в «теневом» режиме: Gateway дублирует запросы одновременно в монолит и в новый сервис, но возвращает клиенту ответ только от монолита. Ответы нового сервиса логируются и сравниваются. Это позволяет выявить расхождения без риска для пользователей.
Шаг 4: Постепенное переключение трафика (Canary Deployment)
Начните с 5–10% трафика на новый сервис, мониторя error rate, latency и бизнес-метрики. Постепенно увеличивайте долю до 100%. После стабилизации удалите соответствующую логику из монолита.
Шаг 5: Удаление мёртвого кода из монолита
Этот шаг часто пропускают, но он критически важен. Код, оставшийся в монолите после миграции, создаёт иллюзию надёжности и накапливает технический долг. После переключения 100% трафика на новый сервис код в монолите должен быть удалён.
Управление данными: стратегии разделения базы данных
Работа с данными — наиболее сложная часть декомпозиции монолита. В большинстве PHP-приложений все данные хранятся в единой базе данных, и многие таблицы используются несколькими доменами одновременно.
Стратегия «Database per Service»
Каждый микросервис должен иметь свою базу данных или схему. Это обеспечивает независимость деплоя и масштабирования. Однако переход к этой модели требует времени и промежуточных шагов.
Промежуточные тактики
- Shared Database, Separate Schema — на начальном этапе сервисы используют один сервер PostgreSQL, но разные схемы. Это снижает операционную сложность при сохранении логической изоляции.
- Database Views — для сервисов, которым нужны данные из «чужих» таблиц, создаются read-only представления. Это фиксирует публичный контракт данных.
- Change Data Capture (CDC) — инструменты типа Debezium отслеживают изменения в таблицах монолита и транслируют их в новую базу через очередь сообщений (Kafka, RabbitMQ). Обеспечивает eventual consistency без прямой связи между сервисами.
- Dual Write — на переходный период приложение пишет данные одновременно в старую и новую базу. Требует тщательной обработки частичных сбоев.
Работа с общими таблицами
Таблица users — классический пример общей таблицы в PHP-монолитах. К ней обращаются практически все модули. Стратегия разделения: выделите из неё только те поля, которые принадлежат выделяемому домену. Например, поля аутентификации (password_hash, remember_token, last_login_at) переходят в auth-сервис, профильные поля (name, avatar, bio) остаются в user-profile-сервисе или монолите.
Практический пример: выделение аутентификации из Laravel в Go
Рассмотрим конкретный кейс: Laravel-монолит с таблицей users и стандартной Laravel-аутентификацией. Задача — вынести аутентификацию в отдельный сервис на Go.
Почему Go для auth-сервиса?
Go обеспечивает низкую latency, минимальное потребление памяти и простое управление конкурентностью — критически важные характеристики для высоконагруженного сервиса аутентификации. Кроме того, это демонстрирует, что паттерн Strangler Fig не привязывает вас к одному языку.
Структура нового auth-сервиса на Go
// auth-service/main.go
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type LoginResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
type AuthHandler struct {
db *sql.DB
jwtSecret []byte
}
// POST /api/v1/auth/login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
return
}
var userID int64
var passwordHash string
// Читаем из auth-схемы, которая реплицирована из монолита
err := h.db.QueryRow(
`SELECT id, password FROM auth.users WHERE email = $1 AND deleted_at IS NULL`,
req.Email,
).Scan(&userID, &passwordHash)
if err == sql.ErrNoRows {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
expiresAt := time.Now().Add(24 * time.Hour)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": expiresAt.Unix(),
"iss": "auth-service",
})
tokenString, err := token.SignedString(h.jwtSecret)
if err != nil {
http.Error(w, `{"error":"token generation failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(LoginResponse{
Token: tokenString,
ExpiresAt: expiresAt,
})
}
func main() {
db, err := sql.Open("postgres", "postgres://auth_user:secret@postgres:5432/app_db?sslmode=require")
if err != nil {
log.Fatal(err)
}
defer db.Close()
handler := &AuthHandler{
db: db,
jwtSecret: []byte("your-secret-key"),
}
mux := http.NewServeMux()
mux.HandleFunc("POST /api/v1/auth/login", handler.Login)
log.Println("Auth service listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Переходный период: Dual Write в Laravel
На время миграции Laravel-монолит продолжает работать с таблицей users, но все операции с паролями дублируются в auth-схему. В Laravel-коде это реализуется через Observer или декоратор над Auth-фасадом. После подтверждения стабильности нового сервиса Dual Write отключается, и Laravel делегирует аутентификацию Go-сервису через внутренний REST API вызов.
Тестирование в процессе миграции
Миграция без надёжного тестирования — это хождение по канату без страховки. Ключевые стратегии:
Контрактное тестирование REST API
Используйте Pact или Dredd для контрактного тестирования: потребитель (например, фронтенд или монолит) описывает ожидаемый контракт, провайдер (новый сервис) верифицирует его соответствие. Это защищает от breaking changes при эволюции API.
Shadow Mode сравнение
В период параллельной работы автоматически сравнивайте ответы монолита и нового сервиса. Расхождения логируются и алертируются. Для PHP-монолита на Laravel удобно использовать middleware, который дублирует запросы и сравнивает JSON-ответы, игнорируя поля с timestamp.
Регрессионное тестирование
E2E-тесты должны покрывать критические пути, проходящие через API Gateway, независимо от того, обрабатываются ли они монолитом или новым сервисом. Инструменты: Postman/Newman для API-тестов, Playwright для e2e.
CI/CD для гибридного окружения
Параллельная работа монолита и микросервисов создаёт дополнительную сложность для процессов CI/CD. Несколько принципов:
Независимые пайплайны
Каждый микросервис должен иметь свой пайплайн сборки, тестирования и деплоя. Монолит не должен быть зависимостью для деплоя нового сервиса. В GitLab CI или GitHub Actions это реализуется через отдельные workflow-файлы с условиями запуска по изменениям в соответствующих директориях.
Версионирование API и конфигурации Gateway
Изменения в конфигурации API Gateway должны быть частью Infrastructure as Code (Terraform, Pulumi) и проходить через тот же ревью-процесс, что и код. Новые маршруты активируются через feature flags, что позволяет откатиться без передеплоя.
Пример структуры репозитория
monorepo/
├── monolith/ # Laravel PHP-монолит
│ ├── app/
│ ├── .github/workflows/monolith-ci.yml
│ └── Dockerfile
├── services/
│ ├── auth-service/ # Go auth-сервис
│ │ ├── main.go
│ │ ├── .github/workflows/auth-service-ci.yml
│ │ └── Dockerfile
│ └── notification-service/ # Следующий сервис
│ └── ...
├── infrastructure/
│ ├── nginx/ # API Gateway конфигурация
│ │ └── strangler.conf
│ ├── terraform/ # IaC
│ └── docker-compose.yml # Локальная разработка
└── contracts/ # OpenAPI-контракты
├── auth-api.yaml
└── notification-api.yaml
Health Checks и Circuit Breaker в пайплайне
После деплоя нового сервиса CI/CD пайплайн должен проверять его health-эндпоинт перед переключением трафика. Если health check не прошёл, Gateway продолжает направлять запросы в монолит. Это обеспечивает zero-downtime при любых проблемах.
Метрики успеха миграции
Отслеживайте следующие показатели на протяжении всей миграции:
- Доля трафика на новых сервисах — целевой показатель растёт от 0% до 100% для каждого выделенного домена.
- Latency P95/P99 — новый сервис не должен деградировать по времени отклика относительно монолита.
- Error Rate — отслеживается отдельно для Gateway, монолита и каждого микросервиса.
- Deployment Frequency — должна расти по мере выделения сервисов; каждый сервис деплоится независимо.
- Mean Time to Recovery (MTTR) — инциденты в изолированном сервисе не должны аффектить остальную систему.
- Размер и сложность монолита — количество строк кода, классов и маршрутов в монолите должно уменьшаться.
- Покрытие контрактными тестами — все публичные API новых сервисов покрыты Pact-тестами.
Паттерн Strangler Fig успешен не тогда, когда последний сервис выделен, а когда каждый промежуточный шаг принёс измеримую ценность: снизил риски, ускорил деплой или упростил масштабирование конкретного домена.
Заключение
Паттерн Strangler Fig — наиболее безопасный и прагматичный путь декомпозиции PHP-монолита. Он позволяет командам двигаться итерационно, проверяя каждое решение в production-окружении, не подвергая риску работоспособность системы в целом.
Ключевые принципы, которые мы разобрали: начинайте с тщательного domain mapping, используйте API Gateway как центральный элемент оркестрации, обязательно реализуйте Shadow Mode перед переключением трафика, решайте вопросы разделения базы данных заранее, а не по факту. Для PHP и Laravel этот путь особенно органичен: REST API — нативный язык коммуникации, а экосистема инструментов (Docker, Kubernetes, Traefik, Pact) полностью покрывает операционные потребности.
Выделение auth-сервиса на Go — лишь первый шаг. За ним последуют notification-сервис, billing, search. Каждый выделенный сервис делает монолит немного меньше и команду — немного свободнее. В этом и есть сила паттерна Strangler Fig: не революция, а эволюция с измеримыми результатами на каждом этапе.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →