# Эксплуатация

Регламент для установки, на которой живут чужие сайты. Всё здесь написано
из одного соображения: у трёхсот арендаторов один процесс, одна база и один
диск, поэтому «перезапустить и посмотреть» — не метод.

## Что должно быть на сервере

| Что | Зачем |
|---|---|
| PHP 8.3+, `pdo_mysql`, `mbstring`, `intl`, `gd`, `zip` | код |
| MySQL 8.0+ | схема опирается на `ngram`, `ON DUPLICATE KEY`, оконные функции |
| Redis | кэш и очередь платформы; автономной копии он не нужен ([почему](cache.md)) |
| nginx или Apache с корнем в `public/` | ⚠️ веб-сервер **не должен резать завершающий слэш** (см. ниже) |
| cron | планировщик Laravel |
| supervisor (или systemd) | воркеры очереди |

⚠️ Расширения `redis` в сборке PHP на разработке нет, поэтому используется
`predis` (`REDIS_CLIENT=predis`). На боевом сервере ставить `phpredis`:
он заметно быстрее, а переключение — одна строка в `.env`.

### Медиа мимо PHP — необязательная оптимизация

Файлы сайта отдаёт маршрут ядра (`/a/{login}/…`), и это работает из коробки
на любом хостинге. Но каждая картинка при этом поднимает приложение целиком.
На сайте с трафиком каталог стоит отдать веб-сервером напрямую:

```nginx
location ~ ^/a/([a-z0-9\-]+)/(img|doc)/(.*)$ {
    alias /путь/к/проекту/storage/app/sites/$1/$2/$3;
    expires max;
    access_log off;
}
```

⚠️ **Только `img` и `doc`.** Рядом лежит `imp` — пакеты обмена с 1С, где цены,
остатки и вся номенклатура арендатора. Открытый наружу `/a/{login}/imp/`
отдаёт их любому, кто угадает имя файла.

Маршрут приложения при этом остаётся: он отработает для всего, что алиас
не перехватил, и продолжит работать на копии клиента, где алиаса нет.

⚠️ **Завершающий слэш веб-сервер не трогает.** Канонический адрес раздела
в KORZILLA X им заканчивается (`/catalog/`), и приложение приводит к нему
301-м редиректом. Правило «убрать слэш» на стороне сервера даёт вечный цикл:
сервер уводит `/catalog/` на `/catalog`, приложение отвечает 301 обратно —
и ни один раздел сайта не открывается.

Такое правило приезжает само: оно есть в `public/.htaccess` из скелета
Laravel. Оттуда оно **удалено**, и это стережёт тест
`PageResolutionTest::the_web_server_does_not_strip_the_trailing_slash_back`.
На встроенном сервере разработки Apache нет, поэтому в тестах беда
не проявляется — нашлось живым прогоном на стенде.

Если сайт стоит за nginx с собственным `rewrite ^/(.*)/$ /$1 permanent`,
это правило надо убрать там же.

## Деплой

```bash
php artisan down                                  # если миграция ломающая
git pull
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate --force
php artisan module:discover
php artisan config:cache && php artisan route:cache && php artisan view:cache
php artisan queue:restart
php artisan up
```

Что важно:

- **`module:discover` обязателен.** В рантайме файловая система не сканируется,
  реестр модулей читается из `bootstrap/cache/korzilla-modules.php`. Новый
  модуль без этой команды просто не существует.
- **`queue:restart` обязателен.** Воркеры — долгоживущие процессы, они держат
  в памяти старый код. Без перезапуска задачи выполняются вчерашней версией.
- **`config:cache` не мешает персайтовым переопределениям**: они применяются
  в рантайме через `config()->set()` уже после загрузки кэша ([platform.md](platform.md#персайтовый-код)).
- **Кэш содержимого не сбрасывается деплоем и не должен.** Инвалидация идёт
  по счётчикам версий; сброс всего кэша на трёхстах сайтах — это триста
  холодных стартов разом.

### Ломающие миграции — через `gh-ost`

`ALTER TABLE` на `catalog_products` в сто тысяч строк блокирует запись
на минуты, а на миллион — на десятки минут. Для таких таблиц миграция идёт
внешним инструментом (`gh-ost` или `pt-online-schema-change`), а Laravel-миграция
только отмечается выполненной.

Правило простое: **если таблица больше 100 тысяч строк — не `ALTER` напрямую**.

## Очереди и расписание

```
* * * * * cd /var/www/korzillax && php artisan schedule:run >> /dev/null 2>&1
```

Воркер под supervisor:

```ini
[program:korzilla-queue]
command=php /var/www/korzillax/artisan queue:work --max-jobs=500 --max-time=3600 --tries=3
numprocs=4
autorestart=true
```

⚠️ **`--max-jobs` не для красоты.** Воркер живёт долго и накапливает
состояние; scoped-объекты сбрасываются перед каждой задачей, но утечки памяти
в сторонних библиотеках так не лечатся. Ограничение по числу задач и времени —
это гарантия, что процесс регулярно начинает с чистого листа.

Что уже висит в расписании:

| Команда | Когда | Что делает |
|---|---|---|
| `catalog:import-watchdog` | ежечасно | закрывает прогоны импорта, переставшие подавать признаки жизни, и убирает брошенный склад ([import.md](import.md#сторож)) |

## Бэкапы

Бэкапить нужно **две** вещи, и обе целиком:

1. **База** — `mysqldump --single-transaction --quick --routines`. Ключ
   `--single-transaction` обязателен по той же причине, по которой выгрузка
   сайта идёт на снимке: без него дамп собирается из разных точек времени
   и заказ уезжает без позиций.
2. **`storage/app/sites/`** — медиа всех арендаторов. В базе лежат только пути.

Своего инструмента для этого не написано намеренно: `mysqldump` и `rsync`
делают ровно то, что нужно, и их поведение известно тысячам людей. Персайтовый
дамп (`site:export`) — это не бэкап, а выгрузка клиенту: он вырезает
платформенные данные и не везёт поисковый индекс.

Проверять бэкап восстановлением, а не наличием файла. Невосстановимый бэкап
выясняется в тот единственный день, когда он нужен.

## Мониторинг

```
GET /health        → 200 «ok» или 503 «fail»
GET /health?token= → то же плюс подробности
```

Наблюдателю достаточно кода ответа: разбирать JSON, чтобы понять, что
установка лежит, он не должен. Подробности закрыты токеном
(`KZ_HEALTH_TOKEN`) — открытая страница состояния подсказывает случайному
прохожему, какие драйверы подняты, сколько свободного места и не отстала ли
очередь.

Проверки ядра:

| Проверка | Что означает «плохо» |
|---|---|
| `db` | база не отвечает |
| `cache` | значение записалось, но не прочиталось: кончилось место или права на каталог |
| `queue` | **старшая задача ждёт дольше 15 минут** — это умерший воркер, а не нагрузка: нагрузка рассасывается |
| `disk` | меньше 500 МБ свободно |

Модуль со своим признаком жизни добавляет проверку сам:

```php
$this->app->make(HealthCheck::class)->register('обмен-1с', fn () => [...]);
```

Отдельно стоит смотреть на то, чего `/health` не покажет:

- время ответа главной страницы арендаторов (внешний аптайм-монитор);
- размер `storage/logs` — растущий журнал обычно означает повторяющуюся ошибку;
- число строк в `import_stage`: склад успешного прогона убирает сам конвейер,
  и накопление означает, что прогоны падают.

## Что делать, когда

**Все сайты отдают 404 на любой адрес.** Скорее всего кэш маршрутов или модулей
собран с ошибкой: `php artisan route:clear && php artisan module:discover`.

**Изменение в админке не видно на сайте.** Счётчик версий кэша не увеличился.
Причина почти всегда одна — массовое обновление в обход writer'а
(`Model::query()->update()` не поднимает события). Разбор — [cache.md](cache.md).

**Очередь стоит.** Проверить `/health` с токеном: если старшая задача ждёт
дольше порога, воркеры мертвы — `supervisorctl restart korzilla-queue`. Если
задачи выполняются, но их много, — это нагрузка, добавить процессов.

**Обмен с 1С не принимает пакеты.** У сайта висит прогон импорта. Сторож
закроет его в течение часа сам; вручную — `php artisan catalog:import-watchdog`.

**Сайт клиента переехал к нему.** См. [export.md](export.md): выгрузка
и `korzilla:install` на его стороне.

## Чего в регламенте ещё нет

| Что | Почему |
|---|---|
| Нагрузочный тест боевой площадки | нужен сервер: локальные замеры есть в [catalog.md](catalog.md) и [import.md](import.md) |
| Своя страница технических работ | `php artisan down` отдаёт страницу Laravel; персайтовой заглушки нет |
| Ротация журналов и сбор метрик | зависит от площадки: `logrotate` + то, что уже стоит у хостера |
| Автоматическая проверка бэкапов восстановлением | делается вместе с первым боевым сервером |
| Оповещения (Telegram, почта) при 503 | внешний монитор умеет это сам |
