Классический сайт на 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 вернёт содержимое на место. Флаг отключает выделение терминала, без него перенаправление файла на вход не срабатывает.
Перед выносом связки на боевой сервер полезно пройтись по короткому чек-листу:
- пароли вынесены в файл окружения, закрыты правами 600 и не попали в репозиторий;
- у MariaDB и Redis нет опубликованных портов, наружу смотрит только Nginx;
- теги образов зафиксированы, обновление остаётся осознанным актом, а не лотереей;
- у базы и кэша настроены проверки здоровья, приложение ждёт готовности через условие service_healthy;
- содержимое базы живёт в именованном томе и охвачено резервным копированием;
- у каждой службы стоит restart unless-stopped, рост журналов ограничен на уровне движка.
Дальше по пути роста стоит добавить TLS перед Nginx, вынести отдачу статики на отдельный слой и озаботиться регулярными снимками тома с базой. Но каркас из четырёх служб, двух сетей и одного файла описания останется прежним, и это главная ценность подхода.