Классический сайт на PHP это почти всегда четыре службы. Nginx принимает запросы из сети, PHP FPM исполняет код, MariaDB хранит содержимое, Redis кэширует горячие запросы и разгружает базу. Поодиночке такие связки ставят часами: конфиги раскиданы по системным каталогам, версии не совпадают, а переезд на новый сервер превращается в археологию. Docker Compose меняет расклад: весь стек описывается одним файлом, поднимается одной командой и переезжает копированием каталога.

В статье собирается связка, пригодная для боевого сервера: раздельные сети, том для базы, проверки здоровья, пароли вне репозитория и порядок обновления. Образы официальные, теги зафиксированы, поэтому через год файл соберёт тот же результат, а не сюрприз из серии вроде бы работало. Заодно исчезают споры о том, какая версия PHP живёт на каком сервере: ответ один, и он в файле.

Зачем стеку раздельные сети и почему наружу смотрит только Nginx

Архитектура начинается с вопроса: кто с кем должен разговаривать. Nginx принимает соединения из интернета, значит ему нужен опубликованный порт. PHP исполняет код и потому общается и с Nginx, и с базой, и с кэшем. MariaDB и Redis нужны только приложению, наружу им смотреть незачем. Логичная схема рождается сама: две сети, front соединяет Nginx и PHP, back связывает PHP с MariaDB и Redis.

Такое деление не эстетика, а граница обороны. Внутри Compose каждая сеть изолирована, служба видит только соседей по своей сети. Если атакующий прорвался в контейнер Nginx, дальше у него тупик: до MariaDB из front-сети не дотянуться, она в другой. Публикация портов тоже однозначна: у Nginx в описании стоит секция ports с портом 80, у MariaDB и Redis её нет вообще, снаружи их не увидит даже сканер. Для соединений между службами работает встроенный DNS Compose: имена php, mariadb и redis разрешаются в адреса контейнеров автоматически, без единой строчки ручной настройки.

Убедиться в разметке помогает docker network inspect: сеть front покажет ровно два контейнера, Nginx и PHP, а сеть back перечислит PHP, MariaDB и Redis. Ничего лишнего нигде не болтается, и это видно глазами.

Внутри back-сети стоит закрыть и Redis. По умолчанию он слушает без пароля, а привычка запускать открытый кэш рядом с боевой базой заканчивается сканами и неприятными письмами. Поэтому в стеке Redis стартует с параметром requirepass, пароль хранится в файле окружения наравне с секретами MariaDB.

Каталог проекта и секреты в файле окружения

Структура проекта компактна: корневой каталог с файлом описания стека и файлом окружения, подкаталог www с кодом приложения, подкаталог nginx с конфигурацией виртуального сервера, подкаталог php с рецептом сборки образа. Никаких скрытых зависимостей, всё лежит на виду и переезжает одним архивом.

Секреты живут в файле .env рядом с описанием стека. Compose читает его автоматически при каждом вызове из каталога проекта и подставляет значения вместо переменных.

MARIADB_ROOT_PASSWORD=Xk9vPm2QsL8wR4tY
MARIADB_DATABASE=appdb
MARIADB_USER=appuser
MARIADB_PASSWORD=Zb7nC3mK9pQ2wE5r
REDIS_PASSWORD=Vt6gY8hJ3fD7sA1x

Файл стоит закрыть правами 600 и не отправлять в Git, иначе секретный набор уедет в историю навсегда. Значения подставляются в описание стека по именам, поэтому пароль не дублируется в двух местах и меняется одной правкой, а не поиском по всем файлам.

Чтобы файл окружения не уезжал в репозиторий случайно, рядом кладут файл .gitignore со строкой .env, а пример значений хранят в .env.example с выдуманными паролями. Коллеги при развертывании скажут спасибо.

Полное описание стека в YAML для Docker Compose

Основной файл описывает четыре службы, две сети и один том. Сокращать его не стоит, каждая строчка несёт смысл.

services:
  nginx:
    image: nginx:stable-alpine
    ports:
      - "80:80"
    volumes:
      - ./www:/var/www/html:ro
      - ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - php
    networks:
      - front
    restart: unless-stopped

  php:
    build: ./php
    image: project-php:8.4
    volumes:
      - ./www:/var/www/html
    environment:
      MARIADB_DATABASE: "${MARIADB_DATABASE}"
      MARIADB_USER: "${MARIADB_USER}"
      MARIADB_PASSWORD: "${MARIADB_PASSWORD}"
    depends_on:
      mariadb:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - front
      - back
    restart: unless-stopped

  mariadb:
    image: mariadb:11.4
    environment:
      MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD}"
      MARIADB_DATABASE: "${MARIADB_DATABASE}"
      MARIADB_USER: "${MARIADB_USER}"
      MARIADB_PASSWORD: "${MARIADB_PASSWORD}"
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - back
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - back
    restart: unless-stopped

networks:
  front:
  back:

volumes:
  dbdata:

Разбор по решениям. Образы зафиксированы тегами: nginx:stable-alpine и redis:7-alpine экономят место, MariaDB 11.4 это ветка с длительной поддержкой, PHP собирается локально из базового образа 8.4. Каталог www монтируется и в Nginx только для чтения с суффиксом ro, и в PHP для работы: отдача статики и исполнение кода идут из одного дерева файлов. Том dbdata прячет файлы базы: контейнер MariaDB можно удалить и поднять заново, содержимое останется на месте. Параметр restart со значением unless-stopped велит службам подниматься после падений и перезагрузок сервера, но не трогать осознанную остановку.

Пара строк заслуживает отдельного объяснения. У службы php указаны и build, и image: первая говорит, из какого каталога собирать образ, вторая задаёт ему имя, по которому образ узнают повторные сборки и соседние сервисы. Секция ports есть только у Nginx, и это принципиально: у контейнера без такой секции нет двери наружу, он слышен лишь внутри своих сетей.

Настройка Nginx и сборка образа PHP с расширениями

Базовый образ PHP FPM не умеет говорить с MariaDB из коробки, расширение pdo_mysql придётся добавить. Для этого в подкаталоге php лежит крошечный Dockerfile.

FROM php:8.4-fpm
RUN docker-php-ext-install pdo_mysql

Сборка превращается в образ project-php:8.4, имя задаётся прямо в описании стека, отдельной команды для сборки не потребуется. Такой образ фиксирует окружение приложения: через полгода сборка даст тот же набор модулей, и расширения не придётся вспоминать по памяти. Для проектов с кэшем байткода в тот же Dockerfile добавляют opcache, принцип не меняется.

Настройки PHP при необходимости переопределяют монтированием собственного файла с расширением ini в каталог /usr/local/etc/php/conf.d/. Так меняют лимиты на загрузку и время исполнения, не трогая образ. На время отладки туда же кладут включение вывода ошибок, а на бою его гасят обратно.

Конфигурация Nginx лаконична. Сервер слушает 80, отдаёт статику из смонтированного каталога и отправляет запросы к скриптам на FastCGI адрес php:9000, имя службы подставит DNS Compose.

server {
    listen 80;
    server_name example.com;
    root /var/www/html;
    index index.php index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass php:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

В боевой конфигурации сюда добавляют TLS с сертификатами Let's Encrypt и заголовки безопасности, сам шаблон при этом не меняется.

Контроль здоровья служб и порядок старта

Молодой стек страдает классической болезнью: приложение стартует раньше базы, соединения летят в пустоту, журнал заполняется ошибками connection refused. Лечится это проверками здоровья. У каждой критичной службы в описании задаётся секция healthcheck: команда опроса, интервал, таймаут и число попыток.

У MariaDB опрос выглядит вычурно, и это осознанно. Скрипт healthcheck.sh из официального образа проверяет сразу две вещи: соединение с сервером и факт инициализации движка. На первичном старте MariaDB создаёт системные таблицы, и в эти минуты сервер отвечает на соединения, хотя ещё не готов. Параметр --innodb_initialized защищает от ложного зелёного света, база считается здоровой только когда реально готова принимать запросы.

Redis опрашивается командой redis-cli с паролем, ответ PONG означает готовность. Дальше в дело вступает depends_on с условием service_healthy: контейнер PHP не стартует, пока база и кэш не пройдут проверку. Nginx хватает обычного service_started, он спокойно переносит короткое отсутствие PHP. Такой порядок убирает гонки на старте, после docker compose up -d стек приходит в рабочее состояние сам, без присмотра и ручных перезапусков.

Полезно прогнать контрольные опросы и руками. Команда docker compose exec redis redis-cli ping должна ответить PONG, а docker compose exec mariadb healthcheck.sh --connect повторит проверку из секции healthcheck вживую. Двойной контроль при первом запуске экономит много нервов.

Запуск, проверка и обновление стека без сюрпризов

Запуск из каталога проекта занимает минуту с небольшим, время уходит на скачивание образов и первую инициализацию базы.

docker compose up -d --build
docker compose ps

В списке служб полезно смотреть колонку STATUS: у MariaDB и Redis статус должен стать healthy, у остальных Up. Быстрая проверка веб-слоя делается curl по локальному адресу, журнал стека смотрится одной командой.

curl -I http://127.0.0.1/
docker compose logs -f php

Для проверки связки приложения с базой достаточно крошечного скрипта в каталоге www.

<?php
$pdo = new PDO(
    'mysql:host=mariadb;dbname=' . getenv('MARIADB_DATABASE'),
    getenv('MARIADB_USER'),
    getenv('MARIADB_PASSWORD')
);
echo 'Соединение с базой установлено';

Обновление стека не требует ритуалов. Свежие образы подтягиваются командой docker compose pull, пересборка своего PHP запускается флагом --build, и docker compose up -d пересоздаёт только те службы, чьи образы или конфигурация изменились. Том dbdata переживает любые пересоздания, содержимое базы не теряется. Откат это правка тега в описании и повторный запуск, поэтому теги фиксируются сознательно, а плавающий latest в продакшне не используется вовсе.

Перед обновлением стоит зафиксировать точные идентификаторы работающих образов командой docker compose images: она показывает хэши текущих версий, к которым можно вернуться, если свежий релиз поведёт себя странно.

Работа с базой тоже не требует открытых портов. Консоль MariaDB открывается командой docker compose exec mariadb mariadb -u appuser -p, утилита спросит пароль из файла окружения и пустит внутрь. Резервная копия делается той же дорогой:

docker compose exec mariadb mariadb-dump -u root -p appdb > backup.sql

Обратная загрузка требует флага -T: команда docker compose exec -T mariadb mariadb -u root -p appdb < backup.sql вернёт содержимое на место. Флаг отключает выделение терминала, без него перенаправление файла на вход не срабатывает.

Перед выносом связки на боевой сервер полезно пройтись по короткому чек-листу:

  1. пароли вынесены в файл окружения, закрыты правами 600 и не попали в репозиторий;
  2. у MariaDB и Redis нет опубликованных портов, наружу смотрит только Nginx;
  3. теги образов зафиксированы, обновление остаётся осознанным актом, а не лотереей;
  4. у базы и кэша настроены проверки здоровья, приложение ждёт готовности через условие service_healthy;
  5. содержимое базы живёт в именованном томе и охвачено резервным копированием;
  6. у каждой службы стоит restart unless-stopped, рост журналов ограничен на уровне движка.

Дальше по пути роста стоит добавить TLS перед Nginx, вынести отдачу статики на отдельный слой и озаботиться регулярными снимками тома с базой. Но каркас из четырёх служб, двух сетей и одного файла описания останется прежним, и это главная ценность подхода.