После того как утром я разобрался с падениями воркеров nginx, обнаружилась вторая проблема: загрузка вложений в XenForo AMS перестала работать. Браузер показывал 500, файлы не прикреплялись. Корень оказался в systemd-харденинге PHP-FPM, который тихо смонтировал весь каталог /usr в режиме read-only — прямо туда, где лежит сайт. Забегая вперёд: харденинг приехал не от Ubuntu — его молча включили в пакетах deb.sury.org между минорными версиями, а причинно-следственная цепочка оказалась не той, что я предположил в первой версии этого разбора. Раздел «Почему проблема появилась именно сейчас» переписан по результатам сверки с journalctl, dpkg.log и apt history.
При попытке прикрепить изображение к статье в AMS браузерная консоль показывала:
Одновременно в интерфейсе XenForo Admin CP появилось предупреждение о минимальных требованиях:
Первая очевидная гипотеза — неправильные права на директории. Проверяю владельца и режим:
Директории принадлежат www-data и доступны на запись. Ставлю 755 с правильным владельцем — не помогает. Ставлю 777 как требует XenForo — тоже не помогает. Проверяю что PHP реально думает об этих директориях:
PHP из командной строки от имени www-data говорит YES, а веб-запросы всё равно падают. Это ключевое противоречие: CLI и PHP-FPM под одним пользователем ведут себя по-разному.
Вместо того чтобы продолжать гадать по коду HTTP, смотрю журнал ошибок самого XenForo. Он живёт не в файлах, а в базе — таблица xf_error_log, в интерфейсе это Admin CP → Логи → Журнал ошибок сервера:
В свежей записи чётко написано:
Вот это другой разговор. Не "permission denied" — а именно "read-only file system". Это значит файловая система примонтирована только для чтения. Иду проверять диск:
Ни одной строки про ошибки диска или remount. df -h показывает /dev/sda4 смонтирован нормально, 27% использования, места полно.
Методично прохожу оставшиеся варианты.
immutable-флаг (chattr):
Флага i нет — директория не заморожена.
AppArmor:
Пусто. AppArmor не блокирует.
open_basedir в PHP и nginx:
Закомментирован, не задан нигде. Обратите внимание на -R во второй команде: каталог sites-enabled состоит из симлинков, а grep -r симлинки внутри каталогов не разыменовывает и молча вернёт пустоту. Для конфигов nginx нужен либо -R, либо grep по всему /etc/nginx — иначе легко получить ложноотрицательный результат и пройти мимо настоящей причины.
Остаётся одна не проверенная вещь — ограничения на уровне systemd-юнита PHP-FPM. Дистрибутивы активно используют systemd-харденинг в пакетных юнитах:
Вывод:
Нашёл — целый hardening-блок. Ключевая директива для моего случая — ProtectSystem=full: она монтирует /usr, /boot и /etc в режиме read-only внутри mount namespace процесса. Цель правильная: если PHP-процесс скомпрометирован через уязвимость, он не сможет модифицировать системные файлы или записать бэкдор.
Проблема в том, что сайт расположен в /usr/www/ — прямо внутри /usr. PHP-FPM работает в изолированном namespace, где весь /usr read-only. На уровне файловой системы права 777 и владелец www-data — но это не имеет значения, потому что ядро блокирует запись на уровне mount namespace ещё до проверки прав.
Гипотезу можно проверить одной командой, вообще не трогая сервис: systemd-run запускает разовый процесс с теми же ограничениями, что и юнит:
Тот же touch без -p ProtectSystem=full проходит. Диагноз подтверждён экспериментально.
Именно поэтому sudo -u www-data php из CLI писало нормально: CLI-процесс запускается в обычном namespace без ограничений systemd. А PHP-FPM через systemctl — в изолированном.
До этого утра запись в /usr/www работала — это факт, а не предположение: последние вложения легли на диск 7 июня в 23:20, и mtime файлов совпадает с upload_date в xf_attachment_data — писал именно веб-процесс FPM, не крон. Дальше я восстановил цепочку по journalctl, dpkg.log и apt history, и она оказалась не той, что я описал в первой версии статьи.
Сначала два разрушенных мифа. Первый — «PHP-FPM давно не перезапускался, процессы жили месяцами». Неправда: journalctl показывает рестарты 21 мая, 28 мая и 4 июня, всегда около шести утра. Это needrestart — он стоит в Ubuntu Server из коробки и после ночных обновлений перезапускает сервисы, державшие в памяти устаревшие библиотеки. Инстанс, работавший перед инцидентом, стартовал 4 июня — и спокойно писал в /usr/www, потому что в юните пакета php8.4-fpm 8.4.18 никакого харденинга не было вовсе. Второй миф — «FPM перезапустился вместе с nginx». Так не бывает: systemctl restart nginx не трогает php8.4-fpm, это независимые юниты без зависимостей, они пересекаются только на unix-сокете.
Реальный виновник въехал на моих собственных руках. 9 июня в 09:30, в разгар тушения nginx, я выполнил:
Я хотел обновить один пакет. Но apt upgrade с аргументом так не работает — это полный апгрейд системы, в котором названный пакет лишь гарантированно участвует. Команда привезла около восьмидесяти пакетов, среди них php8.4-fpm 8.4.18 → 8.4.22. Точечное обновление выглядит иначе: apt install --only-upgrade имя-пакета.
А в сборках deb.sury.org этой весной как раз случилось то самое «одно из обновлений». В юниты php-fpm впервые добавили systemd-харденинг: ProtectSystem=full, PrivateDevices, ProtectKernelModules, ProtectKernelTunables, ProtectControlGroups. До этого юниты Sury были голыми — запрос на харденинг висел в их трекере с ноября 2023 года. Изменение раскатилось по всем поддерживаемым версиям PHP и накрыло чужие системы по всему миру: у ISPConfig, который держит интерфейс в /usr/local/ispconfig, после апдейта началась ровно та же EROFS при корректных правах на диске, тот же диагноз и тот же фикс через ReadWritePaths — их инсталлятор теперь ставит такой override автоматически, если PHP в системе от Sury, а не дистрибутивный.
Запрос на харденинг в трекере Sury: https://github.com/oerdnj/deb.sury.org/issues/2050
Разбор той же проблемы у ISPConfig: https://forum.howtoforge.com/thread...ids-temp-folder-becomes-read-only-syst.95140/
Постинст обновления рестартовал FPM в 09:33 — это был первый старт под новым юнитом. Все зафиксированные сбои загрузки вложений — строго после этой точки.
Так что связь двух инцидентов того утра тоньше, чем я написал изначально: падение nginx не «перезапустило PHP-FPM» — оно вынудило меня на незапланированный полный апгрейд, который и привёз ужесточённый юнит. Не общая причина, а каскад: один инцидент создал условия для второго.
Правильный способ кастомизации systemd-юнита — override-файл. Он не затирается при обновлении пакета, потому что хранится отдельно от оригинального юнита.
Создаю override вручную (через systemctl edit можно, но надёжнее явно):
ReadWritePaths — директива, которая при активном ProtectSystem добавляет исключения: указанные пути остаются доступными на запись, несмотря на общий запрет.
Применяю:
Проверяю что применилось:
Проверяю загрузку вложений в AMS — работает.
Важная оговорка: override привязан к имени юнита. Каталог php8.4-fpm.service.d/ действует только на php8.4-fpm — при переходе на php8.5 его придётся создавать заново, иначе грабли выстрелят повторно с новым номером версии. Это ещё один аргумент в пользу переезда на /var/www, о котором ниже.
1. Контролировать изменения в systemd-юнитах при обновлениях
При обновлении PHP-пакетов проверять что изменилось в юнитах:
2. Проверять hardening-блок после обновления
После любого обновления PHP:
Если там появилось что-то новое и жёсткое — сразу обновлять override. Особенно следить за появлением PrivateTmp: почему — в пункте 6.
3. Переехать на /var/www/
Стандартное расположение веб-данных — /var/www/, а не /usr/www/. Директива ProtectSystem=full намеренно не трогает /var, потому что это место для изменяемых данных (variable data). Если перенести сайт на /var/www/, проблема исчезнет без каких-либо override-файлов — PHP-FPM получит доступ туда автоматически.
Миграция требует изменений в конфигах nginx и config.php XenForo, но делает конфигурацию стандартной и устраняет зависимость от ручных правок юнитов.
4. Разделять диагностику CLI и FPM
Если PHP из командной строки работает, а из веб-запроса нет — это почти всегда разница в окружении процесса, а не в правах файловой системы. Первым делом проверять systemctl cat для ограничений юнита и AppArmor для MAC-политик. Не тратить время на chmod 777.
5. Не доверять apt upgrade с именем пакета
apt upgrade пакет — это не «обновить один пакет», а полный апгрейд системы, в котором названный пакет гарантированно участвует. Именно так 8.4.22 и оказался на сервере посреди другого инцидента. Точечное обновление:
6. Сокет в /tmp — следующая мина
Мой pool слушает unix:/tmp/php8.4-fpm.sock. Это работает только потому, что Sury включил харденинг наполовину: директивы PrivateTmp в юните пока нет — хотя именно её просили первым пунктом в исходном запросе и именно она стоит в дистрибутивных юнитах php-fpm. Появится PrivateTmp в очередном минорном обновлении — FPM создаст сокет в приватном /tmp, nginx его не увидит, и сайт ляжет в тотальный 502 при формально корректных конфигах. Разминирую заранее, переезжая на стандартный /run/php:
За каталог /run/php отвечает tmpfiles.d-сниппет пакета (проверить: ls /usr/lib/tmpfiles.d/ | grep php), так что после ребута каталог появится сам и переезд его переживёт.
Пятиминутный инцидент превратился в полноценную диагностику из-за того, что симптом ("нет прав") указывал совсем в другую сторону от причины (mount namespace). Ключевые точки:
ProtectSystem=full — это хорошая практика безопасности, и отключать её не нужно. Нужно правильно размещать данные сайта: в /var/www/, а не в /usr/www/. И помнить, что «минорное» обновление не обещает минорных последствий: на этой неделе мне это доказали дважды — Canonical, сломав ABI nginx security-патчем, и Sury, молча включив харденинг php-fpm между минорными версиями.
Симптомы
При попытке прикрепить изображение к статье в AMS браузерная консоль показывала:
Bash:
POST https://sysadmin.guru/attachments/upload?type=ams_article&context[article_id]=13 500 (Internal Server Error)
Одновременно в интерфейсе XenForo Admin CP появилось предупреждение о минимальных требованиях:
Bash:
Каталог /usr/www/sysadmin/httpdocs/data должен быть доступен для записи. Измените права доступа на 0777.
Каталог /usr/www/sysadmin/httpdocs/internal_data должен быть доступен для записи.
Ложный след: права доступа
Первая очевидная гипотеза — неправильные права на директории. Проверяю владельца и режим:
Bash:
ls -la /usr/www/sysadmin/httpdocs/ | grep -E "data|internal"
Директории принадлежат www-data и доступны на запись. Ставлю 755 с правильным владельцем — не помогает. Ставлю 777 как требует XenForo — тоже не помогает. Проверяю что PHP реально думает об этих директориях:
Bash:
sudo -u www-data php /tmp/check.php
/usr/www/sysadmin/httpdocs/data: is_writable=YES
/usr/www/sysadmin/httpdocs/internal_data: is_writable=YES
PHP из командной строки от имени www-data говорит YES, а веб-запросы всё равно падают. Это ключевое противоречие: CLI и PHP-FPM под одним пользователем ведут себя по-разному.
Точная ошибка из журнала XenForo
Вместо того чтобы продолжать гадать по коду HTTP, смотрю журнал ошибок самого XenForo. Он живёт не в файлах, а в базе — таблица xf_error_log, в интерфейсе это Admin CP → Логи → Журнал ошибок сервера:
SQL:
SELECT FROM_UNIXTIME(exception_date) AS dt, LEFT(message, 120) AS msg
FROM xf_error_log ORDER BY error_id DESC LIMIT 5;
В свежей записи чётко написано:
Bash:
ErrorException: [E_WARNING] fopen(/usr/www/sysadmin/httpdocs/internal_data/attachments/0/252-427b9be7e75ee3a6c99eee83777b68ae.data):
Failed to open stream: Read-only file system
Вот это другой разговор. Не "permission denied" — а именно "read-only file system". Это значит файловая система примонтирована только для чтения. Иду проверять диск:
Bash:
dmesg | grep -iE "error|readonly|read-only|remount|ext4" | tail -30
Ни одной строки про ошибки диска или remount. df -h показывает /dev/sda4 смонтирован нормально, 27% использования, места полно.
Исключаем другие причины
Методично прохожу оставшиеся варианты.
immutable-флаг (chattr):
Bash:
lsattr -d /usr/www/sysadmin/httpdocs/internal_data/
--------------e------- /usr/www/sysadmin/httpdocs/internal_data/
Флага i нет — директория не заморожена.
AppArmor:
Bash:
aa-status | grep -i php
dmesg | grep -i "apparmor.*DENIED"
Пусто. AppArmor не блокирует.
open_basedir в PHP и nginx:
Bash:
grep -r "open_basedir" /etc/php/8.4/fpm/php.ini
grep -R "PHP_VALUE\|open_basedir" /etc/nginx/
Закомментирован, не задан нигде. Обратите внимание на -R во второй команде: каталог sites-enabled состоит из симлинков, а grep -r симлинки внутри каталогов не разыменовывает и молча вернёт пустоту. Для конфигов nginx нужен либо -R, либо grep по всему /etc/nginx — иначе легко получить ложноотрицательный результат и пройти мимо настоящей причины.
Находим причину: systemd ProtectSystem=full
Остаётся одна не проверенная вещь — ограничения на уровне systemd-юнита PHP-FPM. Дистрибутивы активно используют systemd-харденинг в пакетных юнитах:
Bash:
systemctl cat php8.4-fpm | grep -iE "Protect|Private|ReadOnly|ReadWrite|Inaccessible"
Вывод:
Bash:
ProtectSystem=full
PrivateDevices=true
ProtectKernelModules=true
ProtectKernelTunables=true
ProtectControlGroups=true
Нашёл — целый hardening-блок. Ключевая директива для моего случая — ProtectSystem=full: она монтирует /usr, /boot и /etc в режиме read-only внутри mount namespace процесса. Цель правильная: если PHP-процесс скомпрометирован через уязвимость, он не сможет модифицировать системные файлы или записать бэкдор.
Проблема в том, что сайт расположен в /usr/www/ — прямо внутри /usr. PHP-FPM работает в изолированном namespace, где весь /usr read-only. На уровне файловой системы права 777 и владелец www-data — но это не имеет значения, потому что ядро блокирует запись на уровне mount namespace ещё до проверки прав.
Гипотезу можно проверить одной командой, вообще не трогая сервис: systemd-run запускает разовый процесс с теми же ограничениями, что и юнит:
Bash:
systemd-run --pty -p ProtectSystem=full touch /usr/www/sysadmin/httpdocs/internal_data/probe
# touch: cannot touch '...': Read-only file system
Тот же touch без -p ProtectSystem=full проходит. Диагноз подтверждён экспериментально.
Именно поэтому sudo -u www-data php из CLI писало нормально: CLI-процесс запускается в обычном namespace без ограничений systemd. А PHP-FPM через systemctl — в изолированном.
Почему проблема появилась именно сейчас
До этого утра запись в /usr/www работала — это факт, а не предположение: последние вложения легли на диск 7 июня в 23:20, и mtime файлов совпадает с upload_date в xf_attachment_data — писал именно веб-процесс FPM, не крон. Дальше я восстановил цепочку по journalctl, dpkg.log и apt history, и она оказалась не той, что я описал в первой версии статьи.
Сначала два разрушенных мифа. Первый — «PHP-FPM давно не перезапускался, процессы жили месяцами». Неправда: journalctl показывает рестарты 21 мая, 28 мая и 4 июня, всегда около шести утра. Это needrestart — он стоит в Ubuntu Server из коробки и после ночных обновлений перезапускает сервисы, державшие в памяти устаревшие библиотеки. Инстанс, работавший перед инцидентом, стартовал 4 июня — и спокойно писал в /usr/www, потому что в юните пакета php8.4-fpm 8.4.18 никакого харденинга не было вовсе. Второй миф — «FPM перезапустился вместе с nginx». Так не бывает: systemctl restart nginx не трогает php8.4-fpm, это независимые юниты без зависимостей, они пересекаются только на unix-сокете.
Реальный виновник въехал на моих собственных руках. 9 июня в 09:30, в разгар тушения nginx, я выполнил:
Bash:
apt upgrade libnginx-mod-http-headers-more-filter
Я хотел обновить один пакет. Но apt upgrade с аргументом так не работает — это полный апгрейд системы, в котором названный пакет лишь гарантированно участвует. Команда привезла около восьмидесяти пакетов, среди них php8.4-fpm 8.4.18 → 8.4.22. Точечное обновление выглядит иначе: apt install --only-upgrade имя-пакета.
А в сборках deb.sury.org этой весной как раз случилось то самое «одно из обновлений». В юниты php-fpm впервые добавили systemd-харденинг: ProtectSystem=full, PrivateDevices, ProtectKernelModules, ProtectKernelTunables, ProtectControlGroups. До этого юниты Sury были голыми — запрос на харденинг висел в их трекере с ноября 2023 года. Изменение раскатилось по всем поддерживаемым версиям PHP и накрыло чужие системы по всему миру: у ISPConfig, который держит интерфейс в /usr/local/ispconfig, после апдейта началась ровно та же EROFS при корректных правах на диске, тот же диагноз и тот же фикс через ReadWritePaths — их инсталлятор теперь ставит такой override автоматически, если PHP в системе от Sury, а не дистрибутивный.
Запрос на харденинг в трекере Sury: https://github.com/oerdnj/deb.sury.org/issues/2050
Разбор той же проблемы у ISPConfig: https://forum.howtoforge.com/thread...ids-temp-folder-becomes-read-only-syst.95140/
Постинст обновления рестартовал FPM в 09:33 — это был первый старт под новым юнитом. Все зафиксированные сбои загрузки вложений — строго после этой точки.
Так что связь двух инцидентов того утра тоньше, чем я написал изначально: падение nginx не «перезапустило PHP-FPM» — оно вынудило меня на незапланированный полный апгрейд, который и привёз ужесточённый юнит. Не общая причина, а каскад: один инцидент создал условия для второго.
Решение
Правильный способ кастомизации systemd-юнита — override-файл. Он не затирается при обновлении пакета, потому что хранится отдельно от оригинального юнита.
Создаю override вручную (через systemctl edit можно, но надёжнее явно):
Bash:
mkdir -p /etc/systemd/system/php8.4-fpm.service.d/
cat > /etc/systemd/system/php8.4-fpm.service.d/override.conf << 'EOF'
[Service]
ReadWritePaths=/usr/www
EOF
ReadWritePaths — директива, которая при активном ProtectSystem добавляет исключения: указанные пути остаются доступными на запись, несмотря на общий запрет.
Применяю:
Bash:
systemctl daemon-reload
systemctl restart php8.4-fpm
Проверяю что применилось:
Bash:
systemctl show php8.4-fpm | grep ReadWrite
ReadWritePaths=/usr/www
Проверяю загрузку вложений в AMS — работает.
Важная оговорка: override привязан к имени юнита. Каталог php8.4-fpm.service.d/ действует только на php8.4-fpm — при переходе на php8.5 его придётся создавать заново, иначе грабли выстрелят повторно с новым номером версии. Это ещё один аргумент в пользу переезда на /var/www, о котором ниже.
Что делать чтобы не повторилось
1. Контролировать изменения в systemd-юнитах при обновлениях
При обновлении PHP-пакетов проверять что изменилось в юнитах:
Bash:
# Посмотреть что изменится перед применением — симуляция точечного апгрейда
apt -s install --only-upgrade php8.4-fpm
# После обновления сравнить с override
systemd-delta --type=extended | grep php
2. Проверять hardening-блок после обновления
После любого обновления PHP:
Bash:
systemctl cat php8.4-fpm | grep -iE "Protect|Private|ReadOnly|ReadWrite|Inaccessible"
Если там появилось что-то новое и жёсткое — сразу обновлять override. Особенно следить за появлением PrivateTmp: почему — в пункте 6.
3. Переехать на /var/www/
Стандартное расположение веб-данных — /var/www/, а не /usr/www/. Директива ProtectSystem=full намеренно не трогает /var, потому что это место для изменяемых данных (variable data). Если перенести сайт на /var/www/, проблема исчезнет без каких-либо override-файлов — PHP-FPM получит доступ туда автоматически.
Миграция требует изменений в конфигах nginx и config.php XenForo, но делает конфигурацию стандартной и устраняет зависимость от ручных правок юнитов.
4. Разделять диагностику CLI и FPM
Если PHP из командной строки работает, а из веб-запроса нет — это почти всегда разница в окружении процесса, а не в правах файловой системы. Первым делом проверять systemctl cat для ограничений юнита и AppArmor для MAC-политик. Не тратить время на chmod 777.
5. Не доверять apt upgrade с именем пакета
apt upgrade пакет — это не «обновить один пакет», а полный апгрейд системы, в котором названный пакет гарантированно участвует. Именно так 8.4.22 и оказался на сервере посреди другого инцидента. Точечное обновление:
Bash:
apt install --only-upgrade имя-пакета
6. Сокет в /tmp — следующая мина
Мой pool слушает unix:/tmp/php8.4-fpm.sock. Это работает только потому, что Sury включил харденинг наполовину: директивы PrivateTmp в юните пока нет — хотя именно её просили первым пунктом в исходном запросе и именно она стоит в дистрибутивных юнитах php-fpm. Появится PrivateTmp в очередном минорном обновлении — FPM создаст сокет в приватном /tmp, nginx его не увидит, и сайт ляжет в тотальный 502 при формально корректных конфигах. Разминирую заранее, переезжая на стандартный /run/php:
Bash:
sed -i 's|^listen = /tmp/php8.4-fpm.sock|listen = /run/php/php8.4-fpm.sock|' /etc/php/8.4/fpm/pool.d/www.conf
sed -i 's|unix:/tmp/php8.4-fpm.sock|unix:/run/php/php8.4-fpm.sock|g' /etc/nginx/sites-available/default
systemctl restart php8.4-fpm && nginx -t && systemctl reload nginx
curl -sI https://sysadmin.guru | head -1
За каталог /run/php отвечает tmpfiles.d-сниппет пакета (проверить: ls /usr/lib/tmpfiles.d/ | grep php), так что после ребута каталог появится сам и переезд его переживёт.
Итог
Пятиминутный инцидент превратился в полноценную диагностику из-за того, что симптом ("нет прав") указывал совсем в другую сторону от причины (mount namespace). Ключевые точки:
— Журнал ошибок XenForo дал точную формулировку: "Read-only file system", а не "Permission denied" — это сразу исключило права
— Тест is_writable() из CLI показал YES, хотя из FPM было NO — разница в окружении процесса, а не в файловой системе
— systemctl cat php8.4-fpm показал hardening-блок, появившийся в php8.4-fpm 8.4.22 из deb.sury.org: до этой весны юниты Sury были без харденинга вовсе
— Виновник попал на сервер моим же apt upgrade пакет посреди nginx-инцидента — связь двух поломок оказалась каскадом, а не общей причиной
— Решение: ReadWritePaths=/usr/www в systemd override; стратегически — переезд на /var/www
ProtectSystem=full — это хорошая практика безопасности, и отключать её не нужно. Нужно правильно размещать данные сайта: в /var/www/, а не в /usr/www/. И помнить, что «минорное» обновление не обещает минорных последствий: на этой неделе мне это доказали дважды — Canonical, сломав ABI nginx security-патчем, и Sury, молча включив харденинг php-fpm между минорными версиями.