Старая PHP-система тормозит и пугает: как я модернизирую legacy по кусочку, не останавливая компанию
Переписывать старую систему целиком я не советую: для компании до 50 рабочих мест это долгий и плохо управляемый риск. Работает другое — зафиксировать фактическое поведение программы, поставить старый и новый код за один Nginx и переносить по одному бизнес-сценарию. Ниже порядок работ, конфиги и цифры реального по устройству кейса.
Почему я не начинаю с полного переписывания legacy
Legacy — это не просто старый язык или некрасивый код. Это система, правила которой разъехались по исходникам, базе данных, инструкциям сотрудников и устным договорённостям. В интерфейсе может не быть кнопки «срочный выезд», но диспетчер знает: если дописать в комментарий слово «срочно» и вручную поменять коэффициент, бригада поедет завтра, а смета пересчитается правильно. При переписывании с нуля такие детали теряются первыми. Поэтому любую поддержку и модернизацию ИТ-систем я начинаю с вопроса «что программа делает на самом деле», а не «на чём её переписать».
Мой выбор — постепенное вытеснение старой системы. Она остаётся источником проверенного поведения. Рядом появляется новый контур, а обратный прокси направляет в него только уже перенесённые маршруты. Неудачный релиз откатывается изменением одного правила, а не восстановлением всей компании из резервной копии. Приём называют strangler pattern, но название вторично. Для владельца важнее другое: каждые несколько недель он получает законченный участок, может измерить результат и вправе остановить проект, не выбросив уже вложенные деньги.
Версии всё равно имеют значение. PHP 5.6 не получает исправлений с конца 2018 года, MySQL 5.7 закончилась выпуском 5.7.44 в октябре 2023-го, а CentOS 7 перестала обновляться летом 2024-го. Но я не смешиваю срочное устранение уязвимостей с переделкой архитектуры. Сначала закрываю внешний доступ, ограничиваю сеть, проверяю резервное копирование и журналирование. Затем переношу самые изменяемые и денежные процессы. Красивые названия классов и идеальная структура каталогов подождут. Если хочется сначала посчитать, во что обходится старьё вообще, а не одна программа, — начните с аудита технического долга инфраструктуры.
- Не переписывать систему целиком без карты функций и критериев приёмки.
- Не обновлять одновременно язык, фреймворк, базу и бизнес-логику.
- Сначала защищать периметр и данные, затем улучшать архитектуру.
- Переносить законченный бизнес-сценарий, а не случайный набор файлов.
С чего начать: две недели выясняем, что система делает
Первое, что я прошу, — не техническое задание, а показать обычный рабочий день. Сажусь рядом с менеджером, сметчиком, диспетчером и бухгалтером, записываю путь заявки от звонка до закрывающего акта. Отмечаю ручные исправления, выгрузки в Excel, повторные нажатия и операции, которые разрешены только одному опытному сотруднику. Потом сопоставляю это с HTTP-маршрутами, заданиями cron, таблицами, файловыми каталогами и внешними интеграциями. Почти всегда уже здесь находятся забытые скрипты, которые ночью меняют статусы или пересчитывают цены.
Второй шаг — восстановление из резервной копии на отдельном стенде. Не наличие архива, а именно восстановление. Файл, который никто не пробовал развернуть, я резервной копией не считаю. На стенде измеряю объём базы, время восстановления, кодировки, версии расширений PHP, внешние адреса и жёстко прописанные пути. Одновременно включаю журнал медленных запросов и связываю запросы идентификатором. Без исходных цифр фраза «после обновления стало быстрее» ничего не стоит.
Полное покрытие старого кода модульными тестами я не заказываю: это дорого и редко окупается. Вместо него делаю characterization tests — проверки, фиксирующие текущее наблюдаемое поведение. Беру реальные обезличенные примеры: обычная смета, срочный выезд, смета с посадочным материалом поштучно и в объёме, со скидкой, с частичной оплатой, с повторной печатью акта. Тест отправляет одинаковые данные старой и новой реализации и сравнивает сумму, сроки, статусы и строки спецификации. Если старое поведение выглядит странно, мы отдельно решаем, правило это или дефект.
- Карта бизнес-сценариев и ответственные сотрудники.
- Перечень маршрутов, фоновых заданий и интеграций.
- Проверенное время восстановления базы и файлов.
- Замеры времени ответа, ошибок и медленных SQL-запросов.
- Набор контрольных смет с ожидаемым результатом.
- Критерии отката для каждого переключения.
Кейс «ЗеленьГрад»: что было на входе
«ЗеленьГрад» — компания по озеленению и уходу за территориями, 26 рабочих мест: менеджеры по договорам, два сметчика, диспетчер бригад, склад посадочного материала и бухгалтерия. Сметы, договоры на сезонный уход, график выездов бригад и печать актов жили в одном самописном PHP-приложении, которое восемь лет назад написал фрилансер. Исходный контур: CentOS 7.9, Apache 2.4.6, PHP 5.6.40 из стороннего репозитория и MySQL 5.7.44 на сервере с четырёхъядерным Xeon, 16 ГБ памяти и парой SATA-дисков в RAID1. База — 21 ГБ, фотографии объектов, дендропланы и сформированные PDF — 240 ГБ, в таблице смет около 96 тысяч записей.
Жалоба звучала как «система тормозит», но причин было несколько. Утренний отчёт диспетчера по выездам выполнял запрос с коррелированными подзапросами и в апреле доходил до 80 секунд. Генератор PDF удерживал PHP-процесс и память, пока пользователь повторно нажимал кнопку. Два задания cron могли одновременно присвоить заявке разные статусы. Средние цифры ничего не показывали: медиана открытия карточки сметы — 1,2 секунды, а 95-й перцентиль — 9,4 секунды. В пиковые весенние недели 6,1 % запросов калькулятора заканчивались тайм-аутом или ошибкой 5xx.
У озеленения есть своя особенность: с апреля по июнь систему трогать нельзя вообще — это сезон, когда компания зарабатывает основные деньги. Поэтому обследование мы провели в сентябре, а переключения планировали на октябрь–январь. Первое тестовое восстановление не уложилось в рабочий вечер. В дампе нашлись процедуры с DEFINER, привязанным к удалённому пользователю, а часть PDF приложение искало по абсолютному пути старого сервера. Через 4 часа 50 минут стенд всё ещё не работал. Мы исправили процедуру копирования, вынесли пути в конфигурацию и стали отдельно проверять базу и файловое хранилище. Повторное восстановление базы заняло 38 минут, полный запуск с подключением файлов — 1 час 20 минут. Вот это уже честный RTO, а не обещание администратора.
За первые десять рабочих дней мы описали 34 пользовательских сценария и автоматизировали 58 проверок. Нашли 11 заданий cron при семи задокументированных, две интеграции по HTTP — обмен с 1С и отправку SMS клиентам о выезде бригады — и две ручные выгрузки CSV для склада. Приоритет определили просто: сначала калькулятор сметы и карточка заявки, потому что они влияют на выручку каждый день; затем график бригад и PDF-акты; архивные отчёты оставили в старой системе до конца. Перерисовывать внешний вид всех экранов я запретил — пользователям важнее не потерять скорость работы.
- 26 рабочих мест, 34 зафиксированных сценария.
- 58 автоматических проверок поведения.
- 11 фоновых заданий вместо семи задокументированных.
- 21 ГБ базы и 240 ГБ файлов.
- Исходный p95 карточки сметы — 9,4 секунды.
Какую архитектуру выбрать: модульный монолит и один вход через Nginx
Для компании на 26 мест я выбрал модульный монолит, а не микросервисы. Новый контур — Debian 13, PHP 8.4 и Symfony 7.4 LTS. По официальной таблице php.net ветка 8.4 получает активную поддержку до 31 декабря 2026 года и исправления безопасности до 31 декабря 2028-го. Symfony 7.4 требует PHP не ниже 8.2, исправления ошибок выходят до ноября 2028 года, безопасности — до ноября 2029-го. Такой горизонт мне полезнее, чем модная архитектура с брокером сообщений и отдельной командой эксплуатации. Когда распил на сервисы действительно оправдан, видно по нашему разбору, как мы разделили монолит, — но там и масштаб другой.
Под систему выделили две виртуальные машины: приложению — 4 vCPU, 8 ГБ памяти и 80 ГБ NVMe; базе — 4 vCPU, 16 ГБ и 120 ГБ NVMe. Это не универсальные требования, а конфигурация после замеров этого проекта. Старый сервер ушёл в закрытый сегмент, принимать запросы от него мог только новый Nginx. Сначала новый код обслуживал калькулятор смет и API второй версии, а всё остальное без изменения URI уходило в Apache. Фрагмент рабочего правила:
upstream legacy_app {
server 10.20.30.21:8080;
keepalive 16;
}
server {
listen 443 ssl;
server_name app.example.com;
root /srv/smety/current/public;
location ~ ^/(estimates|api/v2)(/|$) {
try_files $uri /index.php$is_args$args;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param HTTP_X_REQUEST_ID $request_id;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
}
location / {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Request-ID $request_id;
proxy_pass http://legacy_app;
}
}Две строки proxy_http_version 1.1 и пустой Connection добавлены не для красоты: без них директива keepalive в upstream не работает, соединения к Apache открываются заново на каждый запрос. После любой правки я проверяю конфигурацию до перезагрузки — nginx -t, затем systemctl reload nginx. Если новый модуль ошибался, прежний маршрут возвращался за пару минут, потому что изменения схемы базы оставались обратно совместимыми.
PHP-сессии между старой и новой частью мы не делили: формат старой сессии нельзя считать надёжным контрактом. При переходе сотрудник один раз заново входил в новый модуль. Для 26 рабочих мест это оказалось дешевле и безопаснее, чем временный механизм общей сессии, который потом пришлось бы отдельно выпиливать. Любопытно, что жалоб не было ни одной: люди видели, что карточка сметы стала открываться быстрее, и повторный вход им это простили.
- Nginx — единственная точка входа.
- Новый модуль получает только явно перечисленные маршруты.
- Старый Apache недоступен из пользовательской сети напрямую.
- Откат маршрута не требует отката всей системы.
- Микросервисы и Kubernetes для этого масштаба не нужны.
Как перенести код и базу MySQL без двойной записи
Код переносили вертикальными срезами. Один срез включал экран, проверку прав, расчёт, запись в базу, журнал события и ответ. Первым стал калькулятор сметы. Мы не копировали старую функцию на 1500 строк, а выделили входные данные и результат, после чего прогнали 58 контрольных примеров через обе реализации. Расхождения нашлись в двух местах: округление посадочного материала до целых растений и сезонный коэффициент за выезд в выходной. Это были не ошибки нового PHP, а два негласных правила. Их явно записали в тестах и только после этого включили новый калькулятор для четырёх менеджеров, через неделю — для всех.
На период сосуществования обе части работали с одной MySQL 5.7, но владелец каждой таблицы был определён заранее. Двойной записи в две базы я избегаю: один успешный запрос и один неуспешный быстро дают расхождение, которое никто не замечает до квартального отчёта. Схему меняли по принципу expand–migrate–contract: сначала добавляли новую nullable-колонку или таблицу, затем заполняли данные и переключали чтение, а старое поле удаляли только после отключения legacy. Миграции проверялись на копии полного размера, включая время блокировок. Тяжёлый индекс строили в согласованное вечернее окно, а не днём под нагрузкой.
Базу обновили после выключения последнего старого маршрута. Прямой переход MySQL 5.7 → 8.4 официально не поддерживается: в документации Oracle прямо сказано, что серию 8.0 пропускать нельзя. Поэтому копию сначала перенесли на MySQL 8.0.46 — это последний выпуск серии, поддержка 8.0 закончилась в апреле 2026 года, и держать её в работе я бы не стал, только как промежуточную ступень. Повторили прикладные тесты и перешли на MySQL 8.4 LTS. Upgrade Checker из MySQL Shell запускали перед каждым шагом; без --target-version он проверяет совместимость с версией самой оболочки, поэтому второй прогон делали клиентом 8.4. В рабочей базе нашлись нулевые даты и имя столбца, совпавшее с новым зарезервированным словом, — их исправили до переключения.
# шаг 1: 5.7 -> 8.0
mysqlsh -- util checkForServerUpgrade upgrade_check@10.20.30.31:3306 \
--target-version=8.0.46 --output-format=JSON \
--config-path=/etc/my.cnf
# шаг 2: 8.0 -> 8.4 (mysqlsh версии 8.4)
mysqlsh -- util checkForServerUpgrade upgrade_check@10.20.30.32:3306 \
--output-format=JSON --config-path=/etc/mysql/mysql.conf.d/mysqld.cnfНа новом сервере явно задали кодировку, строгий режим и журнал медленных запросов. Конфиг небольшой, зато убирает зависимость от неочевидных значений по умолчанию. Существующие таблицы и их сортировки вслепую не конвертировали: сначала проверили сравнения, уникальные индексы и длину ключей. Старые таблицы в utf8mb3 с индексами по длинным строкам — отдельная ловушка: при переводе в utf8mb4 ключ может перестать помещаться в лимит.
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci
sql_mode=STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
slow_query_log=ON
long_query_time=1- Один владелец данных вместо записи в две базы.
- Обратно совместимые изменения схемы до удаления legacy.
- Проверка миграций на копии полного размера.
- Последовательный путь MySQL 5.7 → 8.0 → 8.4.
- Резервная копия и проверенный план восстановления перед переключением.
Сколько стоит и чем закончилось: цифры и что делать первым
Работы заняли 13 календарных недель и около 640 инженерных часов. Остановки при переносе HTTP-маршрутов не понадобилось; финальное переключение базы заняло 38 минут в согласованное вечернее окно в январе. Весной, в первый же сезон на новой системе, p95 открытия карточки сметы держался на уровне 1,6 секунды против прежних 9,4, доля ответов 5xx и тайм-аутов калькулятора упала с 6,1 % до 0,2 %. Обращения по зависшим расчётам и повторной печати актов сократились с 22 до пяти в месяц. Это не обещание для любого проекта, а измеренный результат этого стенда.
Старая система не исчезла по волшебству. Мы оставили её образ, исходники, дамп и инструкцию запуска в закрытом архиве, но убрали из рабочей сети и прекратили запись данных. Часть редких отчётов не переписывали: их заменили параметризованными SQL-выгрузками. Спорное решение с точки зрения архитектурной чистоты, но правильное по деньгам. Заказывать полноценный интерфейс для отчёта, который открывают дважды в год, я не вижу смысла. Похожую логику — сначала работающий минимум, потом полировка — я применяю и при переводе Битрикса на PHP 8, где старый шаблон держит весь сайт.
Если ваша программа пока работает, начните не с выбора фреймворка. Проверьте, восстанавливается ли резервная копия, закройте неподдерживаемый сервер от внешнего доступа, составьте список критичных сценариев и измерьте хотя бы p95 времени ответа. Если под системой всё ещё CentOS 7, заодно спланируйте переезд с CentOS 7 — отдельно от переписывания кода. Затем выберите один процесс, который часто меняется и хорошо принимается пользователями: сметы, заявки, согласования, документы. Один законченный срез даст больше информации о стоимости всей модернизации, чем месяц обсуждений будущей архитектуры.
- На аудит и стенд заложить первые 1–2 недели.
- Назначить владельца каждого бизнес-сценария.
- Согласовать измеримые критерии успеха и отката.
- Перенести один вертикальный срез и провести пилот.
- Только после пилота уточнять срок всего проекта.
Частые вопросы
Всегда ли старую систему нужно рефакторить?
Нет. Если она изолирована, не имеет доступа из интернета, редко меняется и восстанавливается из бэкапа по проверенной процедуре, её можно оставить под наблюдением. Рефакторинг нужен, когда стоимость изменений, сбоев или неподдерживаемой платформы стала выше стоимости поэтапной модернизации.
Можно ли просто обновить PHP 5.6 до PHP 8.4 на том же сервере?
Сразу в рабочей среде — нет. Между версиями накопились несовместимости и удалённые функции, старый код почти наверняка упадёт в неожиданных местах. Сначала нужен стенд, контрольные сценарии и инвентаризация расширений; часто безопаснее переносить отдельные модули в новое приложение.
Можно ли обновить MySQL 5.7 сразу до 8.4?
Нет. По официальной документации серию 8.0 пропускать нельзя: путь 5.7 → 8.0 → 8.4, с запуском Upgrade Checker перед каждым шагом и проверенной резервной копией до первого.
Сколько времени занимает оценка legacy-проекта?
Для системы небольшой компании я закладываю 1–2 недели обследования. Этого хватает, чтобы проверить восстановление, построить карту интеграций, выбрать первый срез и назвать диапазон стоимости. Точная смета всей переделки до обследования — гадание.
Нужно ли покрывать тестами весь старый код?
Нет. В первую очередь тестируются денежные расчёты, права доступа, смена статусов, обмены и документы. Процент покрытия строк сам по себе бизнес не защищает; важнее набор воспроизводимых сценариев с понятным ожидаемым результатом.
Что делать со старой системой после переноса?
Не удалять сразу. Я сохраняю образ, исходники, последний дамп и инструкцию запуска в закрытом архиве, убираю систему из рабочей сети и запрещаю запись. Через год, если архив ни разу не понадобился, его можно списать.
Источники
- PHP: Supported Versions — Сроки поддержки веток PHP: 8.4 — активная поддержка до 31.12.2026, безопасность до 31.12.2028. https://www.php.net/supported-versions.php
- Symfony 7.4 Release — Symfony 7.4 LTS: требование PHP 8.2+, исправления ошибок до ноября 2028, безопасности до ноября 2029. https://symfony.com/releases/7.4
- MySQL 8.4 Reference Manual: Upgrade Paths — Переход 5.7 → 8.4 только через серию 8.0, серию LTS пропускать нельзя. https://dev.mysql.com/doc/refman/8.4/en/upgrade-paths.html
- MySQL Shell 8.4: Upgrade Checker Utility — Синтаксис mysqlsh -- util checkForServerUpgrade, параметры --target-version, --output-format, --config-path. https://dev.mysql.com/doc/mysql-shell/8.4/en/mysql-shell-utilities-upgrade.html
- MySQL 5.7.44 Release Notes — 5.7.44 (октябрь 2023) — последний выпуск серии 5.7. https://dev.mysql.com/doc/relnotes/mysql/5.7/en/news-5-7-44.html
- nginx: ngx_http_upstream_module — Директива keepalive и требование proxy_http_version 1.1 и пустого заголовка Connection для постоянных соединений к upstream. https://nginx.org/en/docs/http/ngx_http_upstream_module.html#keepalive



