Классический деплой PHP - это сервер, который администратор знает лично: релизы лежат в соседних каталогах, симлинк переключается одной командой, откат занимает минуту, а всё непонятное лечится заходом по защищённому шеллу. Kubernetes меняет предмет разговора: вместо личного знакомства с машиной получается конвейер из картинок, подов и манифестов, и PHP входит в него со своей спецификой - процессами-воркерами, сессиями на диске и привычкой считать, что сервер один. Разберём, что переезжает в кластер без боли, где FPM спотыкается о лимиты и когда виртуальные машины остаются честным выбором.
Как выглядит деплой PHP на классических виртуальных машинах
Каноническая схема с годами отточена: каталог релизов, куда сборка кладёт новую версию, симлинк на текущую, переключение которого и есть деплой, рядом старые релизы для отката. Порядок известен: свежая картинка кода, composer install с продакшен-флагами, миграции, прогрев кэша, переключение, проверка. Вокруг приложения живут supervisor для воркеров очередей, cron для планировщика, logrotate для журналов.
Сильная сторона схемы - предсказуемость для одного сервера. Слабость видна на втором: конфигурации машин расползаются, обновление расширений PHP на пяти узлах делается пять раз, а релиз с изменением окружения требует синхронных правок на каждом узле. Сервера становятся похожими на снежинки - одинаковые на вид, разные внутри, и аварийная ночь на третьем году жизни проекта гарантирована.
Инструменты старого мира при этом дожили в рабочем виде: системы управления конфигурацией прогоняют по машинам единый рецепт, релизные скрипты повторяют переключение симлинка на всех узлах, а обновление версии PHP делается пакетным менеджером операционной системы с перезапуском воркеров по графику обслуживания. Показательная метрика той эпохи - время восстановления узла: на машине с личной историей настроек это вечер, на снежинке без документации - ночь с поиском по заметкам.
Что переезжает в контейнеры и поды при переносе PHP-FPM в Kubernetes
Идея кластера прячет среду исполнения в картинку: вместе с кодом, расширениями PHP и зависимостями. Деплой становится заменой подов на новую версию, откат - возвратом к предыдущей ревизии, конфигурация живёт в манифестах, а окружение подаётся переменными из секретов.
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-fpm
spec:
replicas: 6
template:
spec:
containers:
- name: fpm
image: registry/app:1.42.0
resources:
requests: { cpu: 500m, memory: 256Mi }
limits: { cpu: "1", memory: 512Mi }
env:
- name: SESSION_DRIVER
value: redis
В поде рядом с FPM живёт веб-сервер, отдающий статику и говорящий с воркерами по локальному порту, а общий каталог кода между ними монтируется как временный том. Сборка картинки собирается в два слоя: слой зависимостей кэшируется, слой кода меняется при каждом релизе, и образы по-прежнему весят разумные мегабайты. Заходить внутрь пода для ручной правки - антипаттерн: всё, что поправлено руками, исчезнет при следующем перезапуске.
Сборка картинки собирается слоями, и рецептом закрепляется в файле:
FROM composer:2 AS deps
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts
FROM php:8.3-fpm-alpine
COPY --from=deps /app/vendor /app/vendor
COPY . /app
Слой зависимостей меняется при смене файлов composer и кэшируется между сборками, слой кода - при каждом релизе, и сборка занимает секунды, а не минуты. Расширения PHP ставятся штатным установщиком образа прямо в рецепте, и версия PHP зафиксирована так же жёстко, как версия фреймворка.
Пробы вешаются на эндпоинт, который проверяет готовность принимать трафик, а не только открытый порт:
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
Эндпоинт готовности проверяет доступность базы и кэша, иначе кластер честно пускает пользователей на поды с отвалёнными соединениями.
Диагностика при этом переезжает из защищённого шелла в одноразовые команды: посмотреть журналы пода, выполнить скрипт внутри контейнера, описать события узла. Порог входа выше, зато каждое действие оставляет след в истории и повторяется на любом узле одинаково, а дежурному не нужно помнить, на какой из машин жил нужный лог.
Сессии файлы и загрузки как первая грабли нового стека
Первый сюрприз ждёт в логине: пользователь залогинился, обновил страницу и снова гость. Причина проста - сессии писались в файл на диске пода, а следующий запрос попал на другой под с другим диском. Кластер по умолчанию размазывает трафик, и всё, что хранится локально, живёт один запрос: сессии, временные файлы, кэш в файлах, загруженные изображения.
Лечение выучено экосистемой: сессии в Redis одной переменной окружения, загрузки в объектное хранилище по совместимому протоколу, кэш туда же. То, что обязано переживать поды, обязано жить вне подов, и это правило звучит просто, но на практике требует ревизии всех мест, где приложение молча писало на диск. Локальный том остаётся для мусорных сценариев: временные экспорты, локальный кэш с компенсацией.
Миграция живого проекта делается в другом порядке: сначала сессии переезжают в Redis и неделю гоняются на машинах, потом загрузки переносятся в объектное хранилище пакетом, пути в базе переписываются, а ссылки на файлы отдаются подписанными с ограничением времени жизни, чтобы корзина не светила содержимое наружу. Кластер подключается последним, когда писать на диск больше некому.
Отдельный вопрос - постоянные томы: поды, которым нужна высокая цена пересборки кэша или тяжёлые локальные файлы, получают том по имени, и он переживает пересоздание пода. Злоупотребление томами возвращает старый мир: состояние снова размазано, бэкапы снова ручные, и граница проводится по необходимости, а не по удобству.
# Переменные, без которых в кластере не работает авторизация и загрузки
SESSION_DRIVER=redis
REDIS_HOST=cache.internal
FILESYSTEM_DISK=s3
Очереди крон и планировщики в мире Kubernetes
Воркеры очередей переезжают в отдельный деплоймент с той же картинкой, но другой командой запуска: тот же образ, та же версия кода, другой процесс. Планировщик превращается в CronJob, который запускает одноразовые поды по расписанию, и расписание живёт в манифесте рядом с кодом, а не в системном кроне конкретной машины, о которой никто не помнит.
apiVersion: batch/v1
kind: CronJob
metadata:
name: reports
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: reports
image: registry/app:1.42.0
command: ["php", "artisan", "reports:build"]
Автоматическое масштабирование строится по глубине очереди, а не по процессору: воркеров прибавляется, когда очередь растёт, и убавляется, когда разгребена. Отдельная дисциплина - идемпотентность задач: кластер может запустить сценарий повторно после сбоя узла, и задание, посчитавшее квартальный отчёт дважды, превращает выгоду в переплату.
Масштабирование по глубине очереди закрывает и ночные батчи: днём воркеров держится минимум, к трём ночам по расписанию автоматически прибавляется десяток, к утру очередь разгребена и масштаб вернулся. При завершении воркер обязан добить текущую задачу и не брать новую, поэтому таймаут задачи настраивается меньше периода завершения пода, иначе кластер режет работу на середине и очередь плодит повторные запуски.
Лимиты памяти и CPU как главный источник OOMKill и троттлинга
FPM внутри контейнера живёт по лимитам пода, и первая арифметика нового стека - память: лимит пода делится на вес одного воркера, и результат пишется в pm.max_children. Пятьсот двенадцать мегабайт лимита при восьмидесяти мегабайтах на воркер - это шесть воркеров, а не сто, и попытка поставить больше заканчивается убийством пода ядром по превышению памяти. Приложение при этом не падает, оно просто исчезает, и пользователь видит обрыв соединения.
Вторая арифметика - процессор: лимит в доли ядра работает квантами, и жадный воркер, упершийся в лимит, начинает отвечать рывками, хотя средняя загрузка выглядит спокойно. Запросы к ресурсам важнее лимитов для планировщика: поды с заниженными запросами собираются на один узел и толкаются локтами. Третий класс правды - завершение: при откате и релизе под получает сигнал завершения, FPM добивает текущие запросы, и период ожидания в манифесте должен это покрывать, иначе волну обрывов видно в метриках каждого релиза.
Формула воркеров держится в манифесте рядом с лимитом:
env:
- name: PHP_FPM_PM_MAX_CHILDREN
value: "6"
При изменении памяти лимита правка числа воркеров идёт в одном месте, а не в трёх скриптах настройки. Убитый по памяти под виден в событиях: причина OOMKilled, exit-код 137, и это первое, что смотрят после волны обрывов соединений.
Троттлинг при этом виден в метриках задержек раньше, чем в загрузке: хвост растёт при неизменном среднем, и подбор лимитов без графиков перцентилей превращается в угадывание.
Релизы откат и наблюдаемость в конвейере Kubernetes
Релиз по умолчанию - перекат: новые поды поднимаются, проверяются пробами готовности, и только затем старые получают сигнал завершения. Откат - возврат к прошлой ревизии той же командой, без похода в каталог релизов. История ревизий ограничивается манифестом, и привычный пятничный откат из трёх команд превращается в одну.
Наблюдаемость переезжает из файлов журналов в потоки: приложение пишет в стандартный вывод, кластер собирает, агрегатор ищет. Пробы готовности и живости проверяют не порт, а способность отвечать, и даже соединение с базой - частью эндпоинта проверки, иначе кластер честно пускает трафик на под с отвалённой базой. Конфигурация при этом живёт в системе контроля версий и применяется конвейером: ручные правки из браузера исключаются не регламентом, а архитектурой.
Вокруг приложения собирается обвязка: контроллер входящего трафика терминирует защищённые соединения сертификатами по запросу, сетевые политики разрешают подам общаться только с нужными сервисами, база живёт вне кластера управляемым сервисом, потому что состояние в подах - отдельная дисциплина со своими бэкапами и версиями.
Пробы живости и готовности различаются по смыслу: первая отвечает на вопрос о зависании процесса, вторая - о готовности принимать трафик, и смешение ролей ломает релизы, потому что жёсткая живость убивает поды, которые просто грелись.
Правила выбора между Kubernetes и виртуальными машинами для PHP проекта
Вопросы, честные ответы на которые закрывают выбор:
- нагрузка стабильна и умещается в один-два сервера;
- состав окружений меняется часто, релизы идут чаще раза в неделю;
- сессии и загрузки уже вынесены из локального диска;
- в команде есть человек, умеющий читать манифесты и события подов;
- нужна изолированная копия окружения на каждый пулреквест;
- нагрузка скачет в разы, и масштабировать руками надоело;
- регуляторные требования требуют воспроизводимых окружений.
Первый пункт голосует за виртуальные машины, со второго по шестой - за кластер, седьмой закрывает спор в пользу манифестов.
Переезд существующего проекта на кластер делается тремя шагами: вынос состояния из диска, контейнеризация приложения, перенос окружения в манифесты. Каждый шаг проверяется на стенде, и часть проектов останавливается после первого с пользой - вынос сессий и загрузок окупается и на виртуальных машинах.
Kubernetes не делает деплой проще, он делает его одинаковым: каждая среда собирается из одного источника правды, каждый релиз оставляет след, каждый откат повторяем. Виртуальные машины честнее для небольшого проекта: меньше слоёв, понятнее поведение, дешевле обслуживание без выделенного инженера. Подводные камни PHP в кластере все известны наперечёт - сессии, лимиты, воркеры, крон, - и ни один из них не фатален, если отвечать на вопрос не желанием быть современным, а темпом изменений проекта.
Решение по стеку деплоя живёт годами и пересматривается вместе с командой: проект, выросший из пары серверов, честно переезжает в кластер после выноса состояния, а небольшой продукт спокойно живёт на машинах и не платит за сложность, которой не пользуется.