Классический деплой 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 проекта

Вопросы, честные ответы на которые закрывают выбор:

  1. нагрузка стабильна и умещается в один-два сервера;
  2. состав окружений меняется часто, релизы идут чаще раза в неделю;
  3. сессии и загрузки уже вынесены из локального диска;
  4. в команде есть человек, умеющий читать манифесты и события подов;
  5. нужна изолированная копия окружения на каждый пулреквест;
  6. нагрузка скачет в разы, и масштабировать руками надоело;
  7. регуляторные требования требуют воспроизводимых окружений.

Первый пункт голосует за виртуальные машины, со второго по шестой - за кластер, седьмой закрывает спор в пользу манифестов.

Переезд существующего проекта на кластер делается тремя шагами: вынос состояния из диска, контейнеризация приложения, перенос окружения в манифесты. Каждый шаг проверяется на стенде, и часть проектов останавливается после первого с пользой - вынос сессий и загрузок окупается и на виртуальных машинах.

Kubernetes не делает деплой проще, он делает его одинаковым: каждая среда собирается из одного источника правды, каждый релиз оставляет след, каждый откат повторяем. Виртуальные машины честнее для небольшого проекта: меньше слоёв, понятнее поведение, дешевле обслуживание без выделенного инженера. Подводные камни PHP в кластере все известны наперечёт - сессии, лимиты, воркеры, крон, - и ни один из них не фатален, если отвечать на вопрос не желанием быть современным, а темпом изменений проекта.

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