LAMP на AlmaLinux 9: Apache, PHP 8.3 и MariaDB
Привычка набрать yum install httpd php mysql и получить рабочий веб-сервер закончилась вместе с CentOS. В AlmaLinux 9 пакеты на месте, но PHP спрятан за модульными потоками, вместо MySQL стоит MariaDB, mod_php из схемы убран, а SELinux работает в enforcing с первой загрузки. Ниже полный путь до работающей связки Apache, PHP 8.3 и MariaDB на чистой машине AlmaLinux 9, включая те места, на которых обычно уходит полдня. Все команды одинаково работают на Rocky Linux 9 и RHEL 9: это один и тот же набор пакетов.
Почему AlmaLinux 9, а не CentOS
CentOS 8 закрыли в конце 2021 года, на годы раньше срока, под который все планировали инфраструктуру, а CentOS 7 достиг конца жизненного цикла 30 июня 2024 года. Обновлений безопасности нет ни там, ни там. CentOS Stream жив, но он идёт перед RHEL, а не после него: для тестов это нормально, для сервера, который должен тихо работать пять лет, нет.
AlmaLinux 9 это бинарно совместимая пересборка RHEL 9 с поддержкой до 2032 года. Имена пакетов, пути, названия юнитов systemd и политика SELinux совпадают с RHEL 9, поэтому любая вендорская инструкция, написанная под RHEL 9, применяется дословно. Поддерживаемого обновления с 7 сразу на 9 не существует. ELevate умеет провести машину с 7 на 8, а потом с 8 на 9, и это действительно работает, но на виртуальном железе быстрее и куда безопаснее собрать чистую девятку, перенести данные и переключить DNS. Если это арендованный VPS, старую машину можно держать включённой, пока новая проверяется: откат бесплатный.
Откуда брать PHP 8.3
Обычная команда dnf install php на AlmaLinux 9 вообще не идёт через модуль: PHP 8.0 лежит в AppStream обычными, немодульными пакетами. AlmaLinux продолжает бэкпортировать в них исправления безопасности, так что эта 8.0 не брошена, но версия останется 8.0 на весь срок жизни релиза, тогда как сам проект PHP выпустил последнее обновление безопасности для 8.0 в ноябре 2023 года, а библиотеки и фреймворки давно ушли вперёд. Более новые версии приходят модульными потоками AppStream, которые добавлялись по ходу минорных релизов 9.x, и ни один из них не помечен как поток по умолчанию, поэтому сам собой не включается никакой. Не гадайте, посмотрите, что доступно именно на вашей машине:
dnf module list php
dnf module list mariadb
В зависимости от релиза в списке будут 8.1, 8.2 и 8.3, и ни у одной строки не будет пометки [d]. Если 8.3 там есть и вы хотите остаться только на пакетах дистрибутива, пропустите шаг с EPEL и Remi и в следующем блоке включайте php:8.3 вместо php:remi-8.3, всё остальное в статье не меняется. Здесь выбран Remi, который много лет остаётся основной сторонней сборкой PHP для Enterprise Linux: точечные релизы появляются там за считанные дни, а 8.4 лежит в том же репозитории и дождётся момента, когда придётся идти дальше. Remi требует EPEL, а EPEL на AlmaLinux 9 требует включённого репозитория CRB:
dnf -y upgrade
dnf -y install dnf-plugins-core
dnf config-manager --set-enabled crb
dnf -y install epel-release
dnf -y install https://rpms.remirepo.net/enterprise/remi-release-9.rpm
Перед установкой поток нужно сбросить. Если поток никогда не включали, сброс просто ничего не сделает, а молча подменять поток под уже установленными пакетами dnf не станет, и именно здесь застревает большинство:
dnf -y module reset php
dnf -y module enable php:remi-8.3
dnf -y install php php-fpm php-cli php-mysqlnd php-opcache php-gd php-mbstring php-xml php-intl php-zip php-process
php -v
Два момента. Если дистрибутивный PHP уже был установлен, после включения потока Remi выполните dnf distro-sync, чтобы весь комплект переехал целиком, иначе останется базовый php-common рядом с php-mysqlnd от Remi и конфликт зависимостей. Кроме модуля Remi публикует параллельные пакеты php83-php-*, которые ставятся в /opt/remi и уживаются с другими версиями: это для машин, где нужно несколько версий сразу. Для сервера под один проект модульный поток проще и кладёт бинарник туда, где его все ищут, в /usr/bin/php.
Пара имён пакетов сбивает с толку тех, кто пришёл с CentOS 7. JSON с версии 8.0 вкомпилирован в php-common, отдельного пакета php-json больше нет. А php-mysql не существует давно, драйвер называется php-mysqlnd.
Apache, MariaDB и php-fpm
dnf -y install httpd mariadb-server
systemctl enable --now httpd php-fpm mariadb
Пакет php-fpm кладёт файл /etc/httpd/conf.d/php.conf, и именно он говорит Apache передавать запросы к .php и .phar в сокет FPM /run/php-fpm/www.sock через mod_proxy_fcgi. В том же файле лежат защита .user.ini, строка AddType и DirectoryIndex, то есть php.conf тут единственный конфиг Apache, который приносит PHP. Файла /etc/httpd/conf.d/php-fpm.conf на EL9 нет, что бы ни писали старые инструкции по CentOS: php-fpm.conf, который действительно существует, представляет собой systemd-дополнение в /usr/lib/systemd/system/httpd.service.d/ (Remi кладёт свою копию в /etc/systemd/system/) и всего лишь заставляет httpd подтягивать сервис php-fpm. Команда rpm -qf /etc/httpd/conf.d/php.conf покажет, какому пакету файл принадлежит на вашей машине; у Remi его перечисляют и php, и php-fpm. Сама строка SetHandler стоит внутри блока IfModule, который срабатывает, пока не загружен mod_php, а на EL9 это обычное положение дел: дистрибутив mod_php не собирает вовсе. mod_php здесь нет, и он не нужен: с FPM Apache только проксирует запрос, а PHP работает в отдельном пуле процессов под своим пользователем, и именно это делает изоляцию сайтов возможной. Посмотрите, какой MPM включён в /etc/httpd/conf.modules.d/00-mpm.conf, и не трогайте его без причины.
Как привести MariaDB в порядок
AlmaLinux 9 по умолчанию даёт MariaDB 10.5, в свежих минорных релизах доступны и более новые потоки. Начиная с 10.5 бинарники переименованы, поэтому актуальная команда это mariadb-secure-installation, а mysql_secure_installation оставлен символической ссылкой на тот же скрипт:
mariadb-secure-installation
Скрипт предложит перевести root на аутентификацию unix_socket, убрать анонимных пользователей, запретить удалённый вход под root и удалить базу test. На чистой установке соглашайтесь со всем. В MariaDB 10.4 и новее root обычно уже настроен на unix_socket: mariadb -u root работает для системного root без пароля и не работает больше ни для кого. Это удивляет тех, кто ищет пароль, который никогда не задавал. Проверьте, как есть на самом деле:
mariadb -u root -e "SELECT User, Host, plugin FROM mysql.user;"
Дальше база и пользователь, который не root. В отличие от остальных блоков здесь идут команды SQL, поэтому отдайте их клиенту, а не вставляйте в шелл:
mariadb -u root <<'SQL'
CREATE DATABASE appdb CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'appuser'@'localhost' IDENTIFIED BY 'dlinnaya-sluchaynaya-stroka';
GRANT ALL PRIVILEGES ON appdb.* TO 'appuser'@'localhost';
FLUSH PRIVILEGES;
SQL
Проверьте, на чём сервер слушает. Без явного bind-address MariaDB принимает соединения на всех интерфейсах, а база, доступная из интернета, рано или поздно будет найдена:
ss -tlnp | grep 3306
Если приложение живёт на том же хосте, впишите bind-address = 127.0.0.1 в секцию [mysqld] файла /etc/my.cnf.d/mariadb-server.cnf и перезапустите сервис. Проблему с подключением никогда не решают открытием порта 3306 в фаерволе.
firewalld
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
firewall-cmd --list-all
Если firewall-cmd не найдена, образ собран без firewalld. Либо поставьте его, либо убедитесь, что фильтрация есть на сетевом уровне, но не считайте минимальный облачный образ защищённым по умолчанию.
SELinux, та самая половина дня
Оставьте SELinux в enforcing. Почти любая необъяснимая проблема LAMP на EL9 это вопрос меток с решением в одну строку. Сначала инструменты, в минимальной установке semanage нет:
dnf -y install policycoreutils-python-utils setroubleshoot-server
getsebool httpd_can_network_connect httpd_can_network_connect_db
Оба выключены по умолчанию. Первый блокирует исходящие соединения из веб-стека, поэтому обращение PHP к платёжному API, SMTP-релею или внешнему сервису падает с ошибкой, к сети отношения не имеющей. Второй нужен для базы на другом хосте. Это два независимых переключателя, и в блоке ниже они оба: выполняйте только ту строку, которая вашему приложению действительно нужна, потому что каждый включённый переключатель расширяет то, куда дотянется взломанный сайт:
setsebool -P httpd_can_network_connect on
setsebool -P httpd_can_network_connect_db on
Важная деталь: в EL php-fpm работает в том же домене httpd_t, что и Apache, поэтому эти переключатели управляют вашим PHP-кодом, а не только веб-сервером.
Вторая половина это контексты файлов. Всё под /var/www автоматически получает httpd_sys_content_t, и поэтому держать контент там дешевле всего. Каталогам, куда PHP пишет, нужен httpd_sys_rw_content_t, и это должен быть конкретный подкаталог, а не весь корень сайта. Правило ничего не пометит, пока пути нет, поэтому сначала создайте дерево каталогов, владельца им зададим в следующем разделе:
install -d /var/www/example.com/public/uploads
semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/public/uploads(/.*)?"
restorecon -Rv /var/www/example.com
ls -Zd /var/www/example.com/public /var/www/example.com/public/uploads
Классическая ловушка: mv сохраняет старую метку, cp наследует метку места назначения. Перенесите сайт из /root или /home в /var/www командой mv, и каждый файл останется с типом, который Apache читать не имеет права: получите 403 и ошибку доступа в логе, хотя ls -l выглядит идеально. Старый tar с расширенными атрибутами ведёт себя так же. После любого массового переноса выполняйте restorecon -Rv.
Если Apache слушает нестандартный порт, метка нужна и порту:
semanage port -a -t http_port_t -p tcp 8090
Берите порт, которого в штатной политике ещё нет. 8080 для примера не годится: он уже описан как http_cache_port_t, поэтому -a прерывается ошибкой о том, что порт уже определён, а слушать этот тип Apache и так разрешено, то есть менять было нечего. Если у порта тип уже есть и его нужно сменить, ключ будет -m, а не -a. Команда semanage port -l | grep 8090 покажет, какой случай у вас.
Когда что-то запрещено, читайте запрет, а не гадайте:
ausearch -m AVC,USER_AVC -ts recent
sealert -a /var/log/audit/audit.log
setenforce 0 это не решение. Оно прячет проблему до перезагрузки, после которой она возвращается в самый неподходящий момент.
Свой пользователь PHP для каждого сайта
Пул по умолчанию работает от пользователя apache, а значит любой сайт на сервере может читать файлы и конфиги любого другого. Заведите каждому сайту системную учётную запись и отдельный пул. Сначала пользователь и каталоги:
useradd -r -M -s /sbin/nologin -d /var/www/example.com example
install -d -o root -g root -m 0755 /var/www/example.com
install -d -o example -g example -m 0755 /var/www/example.com/public
install -d -o example -g example -m 0755 /var/www/example.com/public/uploads
install -d -o example -g example -m 0700 /var/www/example.com/sessions
semanage fcontext -a -t httpd_sys_rw_content_t "/var/www/example.com/sessions(/.*)?"
restorecon -Rv /var/www/example.com
Затем файл /etc/php-fpm.d/example.conf:
[example]
user = example
group = example
listen = /run/php-fpm/example.sock
listen.mode = 0660
listen.acl_users = apache
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php-fpm/example-error.log
php_admin_value[open_basedir] = /var/www/example.com/:/tmp/
php_value[session.save_path] = /var/www/example.com/sessions
systemctl restart php-fpm
ls -l /run/php-fpm/
Каталог сессий важнее, чем кажется. Пакетный /var/lib/php/session принадлежит root:apache с правами 0770, поэтому пул под другим пользователем в него даже не заходит, и подкаталог внутри не спасает: родительский каталог всё равно не пускает. Путь должен ещё и попадать в open_basedir, иначе session_start() отвечает ошибкой open_basedir restriction и сессия никуда не пишется. Каталог под корнем сайта снимает обе проблемы сразу: владельцем становится пользователь пула, open_basedir его уже покрывает, а лежит он рядом с public, а не внутри, поэтому Apache из него ничего не отдаёт. Именно listen.acl_users позволяет Apache подключиться к сокету, принадлежащему root. Мастер-процесс FPM работает от root, поэтому сокет и так достаётся root, а listen.owner или listen.group рядом с ACL дают только запись is ignored в логе при каждом старте. Если файловая система не поддерживает ACL, уберите строку acl и поставьте вместо неё listen.group = apache, оставив режим 0660.
Значение pm.max_children считайте по реальной памяти: измерьте RSS нагруженного воркера и разделите на него объём памяти, который готовы отдать PHP. Двадцать процессов по 80 МБ это 1.6 ГБ до того, как базе достанется хоть что-то. На маленьком VPS держите число низким и позволяйте запросам стоять в очереди, для нагруженного проекта выделенный сервер даёт запас, чтобы его поднять.
Минимальный виртуальный хост
В Enterprise Linux нет sites-available и sites-enabled. При старте читается всё, что лежит в /etc/httpd/conf.d и заканчивается на .conf. Создайте /etc/httpd/conf.d/example.com.conf:
<VirtualHost *:80>
ServerName example.com
ServerAlias www.example.com
DocumentRoot /var/www/example.com/public
<Directory /var/www/example.com/public>
AllowOverride All
Require all granted
</Directory>
<FilesMatch \.(php|phar)$>
SetHandler "proxy:unix:/run/php-fpm/example.sock|fcgi://localhost"
</FilesMatch>
ErrorLog /var/log/httpd/example.com-error.log
CustomLog /var/log/httpd/example.com-access.log combined
</VirtualHost>
Блок FilesMatch внутри виртуального хоста перекрывает глобальный из пакетного php.conf, который указывает на www.sock. Без него сайт работает на общем пуле по умолчанию от пользователя apache, и вся изоляция выше не значит ничего. Перечисляйте те же расширения, что и пакетный блок, .php и .phar: если оставить только .php, запрос к файлу .phar всё равно уйдёт в глобальный блок и выполнится в общем пуле от apache, то есть ровно в той дыре, ради которой пул и заводили. Приветственная страница AlmaLinux приходит из /etc/httpd/conf.d/welcome.conf: файл можно опустошить, но не удаляйте его, обновление пакета вернёт его обратно.
Как проверить всю связку
httpd -t
php-fpm -t
systemctl restart httpd php-fpm
systemctl is-enabled httpd php-fpm mariadb
curl -sI -H "Host: example.com" http://127.0.0.1/
Предупреждение httpd -t о невозможности определить полное доменное имя безвредно, если мешает, задайте ServerName глобально. Теперь докажите, что PHP действительно выполняется и именно в нужном пуле:
printf '%s\n' '<?php echo PHP_VERSION, " as ", posix_getpwuid(posix_geteuid())["name"], "\n";' > /var/www/example.com/public/v.php
curl -s -H "Host: example.com" http://127.0.0.1/v.php
rm -f /var/www/example.com/public/v.php
В ответе должны быть версия и пользователь пула, а не apache. Функции posix живут в пакете php-process, поэтому он и стоит в строке установки выше: без него страница обрывается на неизвестной функции вместо ответа. Файл сразу удалите. Страницу phpinfo на публичном сервере оставлять нельзя, она дарит атакующему пути, список расширений и конфигурацию.
Базу проверяйте через веб-сервер, а не из консоли. Однострочник php -r от root выполняется вне ограниченного домена и подключится без проблем, тогда как тот же код под httpd_t упадёт, и человек начинает искать несуществующую ошибку в PHP. Делайте это только после того, как проверка выше вернула пользователя пула: если корень сайта ещё не выполняет PHP, файл отдадут как текст вместе с паролем:
printf '%s\n' '<?php $d = new PDO("mysql:host=localhost;dbname=appdb;charset=utf8mb4", "appuser", "dlinnaya-sluchaynaya-stroka"); echo $d->query("SELECT VERSION()")->fetchColumn();' > /var/www/example.com/public/db.php
curl -s -H "Host: example.com" http://127.0.0.1/db.php
rm -f /var/www/example.com/public/db.php
Логи и типовые отказы
tail -f /var/log/httpd/example.com-error.log
tail -f /var/log/php-fpm/example-error.log
tail -f /var/log/mariadb/mariadb.log
journalctl -xeu php-fpm
ausearch -m AVC -ts recent
503 от Apache со строкой connection refused или permission denied означает, что сокета FPM нет или он недоступен. Проверьте, стартовал ли php-fpm, существует ли /run/php-fpm/example.sock и указан ли apache в listen.acl_users. Помните, что /run это tmpfs, сокет пересоздаётся при каждом старте, и создавать его руками бессмысленно.
Если браузер скачивает .php вместо выполнения, обработчик не сработал: либо не загружен mod_proxy_fcgi, либо FilesMatch стоит вне виртуального хоста. 403 на файлах, которые от root читаются, почти всегда метка SELinux. Совершенно пустая страница со статусом 200 это фатальная ошибка PHP при выключенном display_errors, и она уже лежит в логе пула. Сессии, слетающие на каждом запросе, упираются в тот же каталог сессий: либо пул не может в него писать, либо путь остался вне open_basedir.
Когда связка отвечает на 80 порту, до появления реального контента добавьте TLS: поставьте mod_ssl, откройте сервис https в firewalld и выпустите сертификат. Если обновлять пакеты и следить за метками удобнее чужими руками, наши инженеры на связи круглосуточно, а серверы стоят в нашем собственном дата-центре в Риге, смотрите ИТ-поддержку.
Читайте дальше
Готовы начать?
Запустите за минуты или обсудите с инженером, что подходит вашему проекту.