Оптимизация OpenCart: сервер душит магазин — 2 кейса с цифрами
Короткий вывод: что чинить первым
Дешевое и безопасное всегда идет первым. Включить gzip — сервер начинает отдавать около 50 КБ вместо 728 КБ, что особенно заметно на медленном мобильном соединении. Параллельно — вынести сканеров в deny и прикрыть тяжелый поиск лимитом: эти два шага снимают фоновую нагрузку с базы, пока вы разбираетесь с запросами.
Дальше — видимость. В этом кейсе первый фулскан нашелся в день включения slow log, а точечный индекс превращает выборку по заказам из 3.65 секунды в 0.001. Порядок диагностики: сначала замеры и дешевые шаги (gzip, лимиты, slow log), настройки FPM/fcgid — после, с замером до/после каждого шага. Логи ротировать обязательно: один раздутый error.log объемом около 330 МБ добавляет запись на каждый хит.
Отдельная ветка — жалобы с iPhone на частичную загрузку. Там свой нюанс с TLS 1.3, о нем ниже своим разделом. Не смешивать с общей оптимизацией.
Почему OpenCart упирается в сервер
Первая причина — несжатый HTML. OpenCart с темой Revolution собирает главную на 700+ КБ: сотни картинок в кеше, длинные списки товаров, прелоадер. Без gzip все это летит как есть, и на медленном мобильном интернете первая загрузка тянется секундами. Проверка занимает десять секунд: запросить страницу с заголовком Accept-Encoding и без, сравнить вес.
Вторая — ответы через диск и холодный пул PHP. В этом кейсе при дефолтных fastcgi_buffers большие ответы nginx складывал во временные файлы — это прямо видно по buffered to a temporary file в error.log. А пул PHP-FPM в режиме ondemand с idle timeout 10 секунд завершает простаивающие воркеры — после паузы следующий запрос может получить холодный старт.
Третья — слепые тяжелые запросы. База всего 340 МБ, а MySQL ест больше ядра CPU сутками. Значит, дело не в размере, а в кривых выборках: поиск с LIKE, глубокий OFFSET на page=54, фильтры без индексов. Без slow log вы этого не увидите — будете гадать.
Четвертая — боты и мусор. Сканep, перебирающий 1145 служебных URL с одного IP. Скраперы, листающие поиск до 19-й страницы. Раздутые журналы systemd, старые разборы логов в /tmp на 8 ГБ, storage/logs с позапрошлогодними файлами. Каждый пункт по отдельности мелочь, вместе — проценты CPU и гигабайты диска.
Кейс 1: магазин А — холодный TTFB, gzip и MySQL
Магазин на OpenCart 2.3.0.2 с темой Revolution 5.2.1 и каталогом около 34 тысяч товаров (33,9 тысячи позиций в базе на момент диагностики; в мае было 26,9 тысячи — каталог растет). Сервер: 4 ядра, 7.8 ГБ памяти, диск 78 ГБ, панель ISPmanager, связка nginx → PHP-FPM 7.4. Жалоба клиента звучала знакомо: первый заход грузится долго, потом нормально.
Замеры с ноутбука показали: TTFB главной 2.13 секунды холодным хитом и 1.63 теплым. Только TLS-рукопожатие занимало около 0.36 секунды, остальное — бэкенд. Категория /furnitura холодным хитом отвечала 12.66 секунды. HTML главной весил 728 837 байт, и gzip-тест подтвердил худшее: с заголовком сжатия и без вес одинаковый. Сжатие было выключено полностью, весь блок gzip в nginx.conf стоял закомментированным.
Диск был забит на 88%: 68 из 78 ГБ. Разбор показал 8.2 ГБ наших же старых разборов логов в /tmp, 3.9 ГБ журнала systemd, 145 МБ кеша пакетов и около 134 МБ протухших логов OpenCart за прошлый год. База при этом весила всего 339.5 МБ — не она съедала диск. Драйвером роста оказались картинки: кеш и каталог прибавили 5 ГБ за четыре месяца. Чистка безопасного мусора дала 68 → 56 ГБ, свободно стало 23 ГБ вместо 10.
Волна 1 заняла около трех часов и три reload nginx (R1, R2 плюс внеплановый R3 — сужение маски поиска). Gzip включили вместе с deny сканера одним reload: главная стала отдавать 49 964 байта вместо 728 837, минус 93%. Прогноз был скромнее, факт вышел лучше. Сканep 203.0.113.36, до этого получавший 404 и 301, в ту же минуту начал получать 403. Поиск прикрыли лимитом 10 запросов в минуту с burst 5 — но с первой попытки маска задушила живой ajax-подсказчик, который шлет 8 запросов за три секунды набора. Пришлось сузить маску до точного product/search и выставить статус 429 вместо дефолтного 503. Проверка: восемь подсказок — все 200, флуд из 12 поисков — 11 раз 200 и один 429.
Диагностика дала первый улов в тот же день: фулскан таблицы oc_order_product на 869 тысяч строк за 3.8 секунды. Таблица росла быстро — 97 МБ и 285 тысяч строк в мае, 125 МБ и 868 тысяч в сентябре. Точечный индекс по order_id создался за 11.8 секунд в тихом окне и превратил типовую выборку из 3.65 секунды в 0.001. Фикс в одну строку убрал notice темы Revolution, сыпавший сотнями записей в день. Отдельно закрыли netdata, торчавшую наружу без пароля, — теперь только через SSH-туннель. Похожий разбор несжатых страниц описан в статье как ускорить сайт без плагинов — механика gzip и буферов там та же.
Кейс 2: магазин Б — лавина 500-х, fcgid и сканер
Магазин ноутбуков на OpenCart 3.0.3.9. Каталог небольшой — 424 товара, то есть душила не витрина, а сервер. Сервер: Beget Cloud, Ubuntu 24.04, FASTPANEL2, nginx 1.28 + Apache 2.4.58 через mod_fcgid, PHP 7.4.33 на сайте, MySQL 8.0.44. Жалоба владельца: сайт не открывается на iPhone, в Москве и Петербурге.
Проверка на трех живых iPhone сайт открыла. Дальше смотрели логи за четыре дня: всего 2840 пятисотых, из них с iPhone — 54. Остальные 2786 — десктоп и боты. Вывод прямой: проблема не iOS-специфична, iPhone просто оказался тем экраном, на котором ее заметили. Пик пятисотых совпал по времени с активностью сканера 203.0.113.6 — 1200+ запросов в час с перебором /.git/objects. Вклады не изолировались, корректно говорить о совпадении и комплексе причин, а не о доказанной причине: в 15:00 — 10 пятисотых на 1466 запросов, в 16:00 — 1217 на 2620, это 46%, в 17:00 — 552 на 3970, в 18:00 — 6 на 1498. В тот же час один IP перебирал /.git/objects тысячами запросов — классический скан.
Механизм такой: PHP-воркер не успевал или рвалась связка Apache mod_fcgid на дефолтных таймаутах без recycle — в backend-логе в секунды пятисотых лежал ap_pass_brigade failed, Broken pipe с тем же IP. Усилители: флуд ботов, error.log объемом около 330 МБ с записью на каждый хит, полтора миллиона Notice и 400 тысяч Warning от OCMOD-модификаций, плюс .git на 2.3 ГБ прямо в webroot. Тела 500-х — около 14 КБ (замерено). Похоже на полный шаблон через бутстрап: если так, каждая ошибка стоит полного PHP-прохода. Это интерпретация, не замер.
Лечение шло комплексом из шести блоков: бан сканера и deny dotfiles на двух слоях, вынос .git за пределы webroot, fcgid с IOTimeout 90, BusyTimeout 300, MaxRequestsPerProcess 500 и MaxProcesses 150, выключение Notice и Deprecated в обход захардкоженного E_ALL в index.php и startup.php, ротация error.log с исходных около 330 МБ до 2.5 МБ в архиве, slow log с порогом 2 секунды и healthcheck трех страниц с iPhone user-agent каждые пять минут. Проверка тем же калибром, что ночью давал 10 из 10 пятисотых: после фикса 10 из 10 ответов 200 за 0.5–1.9 секунды, 39 запросов в минуту — ноль пятисотых. Разбор кодов ответа сервера подробнее есть в материале про ошибку 500 — логика чтения access и error логов переносится один в один.
Здесь нужна оговорка, которую подтвердил независимый аудит 26 сентября: менялись бан, fcgid, error_reporting, логирование и .git одновременно, поэтому вклады по отдельности не изолированы. Корректная формулировка — комплекс устранил массовый сценарий. Один бот с 6 запросов в секунду за 3 секунды все еще кладет /search — порог отказа единичных rps смягчен, но не закрыт.
Нюанс TLS 1.3 и iPhone: что сработало, чего не знаем
Отдельная история магазина Б, и ее просили рассказать обязательно. Жалоба: iPhone на iOS 26.6.1, любая страница грузится частично — видны куски, пустые блоки, дальше не идет. Во всех браузерах сразу, потому что на iOS один WebKit. Во всех сетях: два проводных провайдера и два мобильных. С VPN и без. Время фиксации — 16:07.
Логи за 16:00–16:25 не показали серверной причины: ноль 500/502/503, healthcheck зеленый весь день. Массовый 5xx-сценарий исключен, но это не доказывает отсутствие любой другой серверной или сетевой проблемы. С 16:00 до 16:25 — ноль 500/502/503, только 200/206/304 и бот-404. Healthcheck весь день зеленый: 200 за 0.3–0.7 секунды. Нагрузка 0.2, свободно 5.5 ГБ памяти, диск наполовину пуст, MySQL slow — ноль. HTML для iPhone и десктопа байт в байт одинаковый.
Помог совет поддержки Beget: отключить TLS 1.3, оставить 1.2. Владелец подтвердил — частичная загрузка ушла. Тот же прием помог второму проекту. Но механизм спорный, и молчать об этом нельзя: симптом сохранялся с VPN, так что сводить все к провайдерскому пути нельзя. Аудит прямо запретил называть HTTP/2 причиной частичного рендера — доказано только то, что 242 запроса по HTTP/1.1 замедляют загрузку. Честная позиция: фикс сработал дважды, причина на уровне сети и стека не установлена.
Грабли nginx при этом вполне конкретные и их стоит знать. Директива ssl_protocols в server-блоке сайта не работает — версию диктует default_server. Рабочая точка оказалась в conf.d/parking.conf с server_name _. В http-контекст директиву ставить нельзя: там висит listen 443 quic, которому нужен 1.3. Проверка простая: openssl с флагом tls1_3 должен получить alert protocol version, с tls1_2 — успешное соединение.
openssl s_client -connect example.ru:443 -tls1_3
openssl s_client -connect example.ru:443 -tls1_2
Для TLS 1.3 ожидаем отказ с alert protocol version, для TLS 1.2 — успешное соединение. HTTP 200 дополнительно проверяется обычным HTTPS-запросом. И помнить: FASTPANEL перезаписывает конфиг сайта при работах в панели, строки проверять после. У магазина А, кстати, тоже давно стоит только TLSv1.2 — решение еще с июля.
Чеклист ремонта: 7 шагов с командами
Шаг 1. Снять эталоны до любых правок. Два замера curl главной и категории, df, хвост error.log как образец нормы. Без эталонов потом не доказать, что стало лучше.
curl -o /dev/null -s -w "ttfb %{time_starttransfer} total %{time_total} code %{http_code} size %{size_download}\n" https://example.ru/
curl -o /dev/null -s -H "Accept-Encoding: gzip" -w "gzip-size %{size_download} code %{http_code}\n" https://example.ru/
df -h /
nginx -t
Шаг 2. Включить gzip в http-контексте nginx.conf и проверить вес. Картинки и архивы не трогать, добавить в типы image/svg+xml.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
Шаг 3. Задать fastcgi_buffers, чтобы большие ответы перестали складываться на диск. Ориентир из живых конфигов:
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
Шаг 4. Включить MySQL slow log без рестарта и закрепить drop-in навсегда. Порог — 2 секунды. На Beget Cloud и Timeweb путь к конфигам подскажет панель, сам прием одинаковый.
SET GLOBAL slow_query_log = 1, slow_query_log_file = '/var/log/mysql/mysql-slow.log', long_query_time = 2;
-- навсегда: /etc/mysql/mariadb.conf.d/99-shop-slow.cnf
-- ротация уже покрывается /etc/logrotate.d/mysql-server (проверить, что маска включает slow-лог)
Нужны права SUPER/root: в кейсе у site-юзера их не было, включали через root-socket. SET действует до рестарта MySQL, навсегда — только drop-in.
На Reg.ru с VPS и ISPmanager прием тот же, но после любых операций панели сверяйте конфиг: ISPmanager умеет молча вернуть дефолты, как это было с gzip-блоком и путями логов в первом кейсе.
Шаг 5. Найти главного пожирателя в slow log и дать точечный индекс в тихом окне. Пример из кейса: создание заняло 11.8 секунды, типовая выборка стала отвечать за миллисекунды.
EXPLAIN SELECT * FROM oc_order_product WHERE order_id = 12345;
ALTER TABLE oc_order_product ADD INDEX idx_order_id (order_id);
Шаг 6. Вынести сканеров в deny и прикрыть тяжелый поиск. Отдельный файл в vhosts-resources, один reload. Маску лимита — только на точный product/search, иначе задушите живой ajax-подсказчик.
deny 203.0.113.36;
# http-контекст, один раз (в кейсе — отдельный search-limit.conf):
limit_req_zone $binary_remote_addr zone=search:10m rate=10r/m;
# server/location, только на точный поиск:
limit_req zone=search burst=5 nodelay;
limit_req_status 429;
Шаг 7. Почистить безопасный мусор и закрыть торчащие сервисы. Наши старые разборы в /tmp, журнал старше недели, протухшие логи storage — в архив локально, потом удаление. Netdata — только на localhost, смотреть через SSH-туннель. После каждой правки в панели ISPmanager или FASTPANEL проверять конфиг: панели перезаписывают index, rewrite SEO-URL и пути логов.
journalctl --vacuum-time=7d
ls -la /tmp/all_logs*.txt # файлы конкретного кейса (магазин А) — сверить перед удалением
rm /tmp/all_logs.txt /tmp/all_logs2.txt
ssh -L 19999:localhost:19999 root@203.0.113.10
Проверка результата
После каждого шага — свой замер, не в конце все разом. TTFB главной и категории до и после, вес страницы с gzip и без, diff slow log, df, хвост access по кодам, две минуты error.log без emerg и crit. Критерии отката простые: 500 или 502 на эталонах, TTFB вдвое дольше пяти минут, новые emerg/crit, не открывается корзина даже на чтение. Любой пункт — откатить вызвавший шаг сразу.
Сводно, ДО → ПОСЛЕ. Магазин А (каталог около 34 тысяч товаров): главная 728 837 байт → 49 964; TTFB главной 2.13/1.63 с → около 2 с; furnitura холодным хитом 12.66 с → около 2 с; выборка заказов 3.65 с → 0.001 с; диск 68 ГБ/88% → 56 ГБ/72%; пять минут наблюдений — 1929 ответов 200 без 500/502/503. Магазин остался работать штатно при растущем каталоге; честная оговорка — MySQL держится около 97%, глубокая пагинация и фильтры ушли в следующий заход. Магазин Б: худший час 46% пятисотых → 1.2%; фон 710 в день → 37 в день (срез 37 на 23102); нагрузочный калибр 10 из 10 пятисотых → 10 из 10 двухсотых за 0.5–1.9 с.
Когда способ не сработает
Глубокая пагинация лечится не сервером. Листалки вида page=54 с полумегабайтом HTML — это тяжелые OFFSET-запросы, их режут лимитами выдачи и robots-правилами для поиска, а не буферами. Один настойчивый бот с 6 запросов в секунду все равно положит /search — там нужен отдельный порог на уровне веба, у магазина Б он остался в бэклоге.
PHP 7.4 с истекшей поддержкой — отдельный долг, не часть ремонта. Смешивать его с ликвидацией лавины 500-х нельзя: обновление мажорной версии PHP на OpenCart с OCMOD и старой темой — свой проект со своим окном. То же с публичным файлменеджером в afm и дампами БД в webroot: это следующий слой P0 по безопасности, а не причина прошлой лавины.
Обмен с 1С живет своей жизнью: TEMP-каталог, ночные выгрузки по 10 ГБ, сотни запросов в сутки. Его не трогать без окна и без владельца — одна чистка не в том часу остановит обмен. И диск сам по себе TTFB не дает, но при 9.8 ГБ свободно одна полная выгрузка плюс пик логов упираются в 95% — чистить до пика, не после.
Частые вопросы
OpenCart тормозит или сыплет 500-ми? Посмотрю логи и скажу, что душит сервер
Диагностика за 1 день: эталоны, slow log, боты, конфиги. Дальше — волны фиксов с замерами.