Привязать службу Linux к тому: что выбрать из systemd
АйТи Фреш
Серверы и инфраструктура

Служба и отдельный том: какую защиту от записи в корень выбрать и чем она отличается от соседней

Автор: , директор ООО «АйТи-Фреш» · · ~15 мин чтения
Заслон перед пустым слотом диска не пускает документы на корневой раздел: привязка службы Linux к тому
Пустой каталог без тома должен быть закрыт, а не гостеприимен.

Чтобы служба не писала в корневой раздел без тома, одной директивы мало: нужны требование монтирования (RequiresMountsFor), остановка при потере тома (BindsTo) и запрет записи в пустой каталог (chattr +i). Ниже разбираю, что каждый механизм ловит, а что пропускает, и даю конфиг, который я проверяю тестовым umount.

Что именно нужно защитить: запуск, работу или запись мимо

Вопрос «как привязать службу к тому» на деле состоит из трёх разных вопросов, и путают их почти всегда. Первый: не запускать службу, пока том не смонтирован. Второй: остановить её, если том пропал уже во время работы. Третий: даже если первые два не сработали, не дать процессу создать файл в пустом каталоге на корневом разделе. Для каждого есть свой механизм, и они не взаимозаменяемы. Если вы настраиваете серверы и службы Linux сами, начните с того, что выпишете эти три вопроса на бумагу и против каждого поставьте директиву. Звучит занудно, зато потом не придётся объяснять директору, почему сканы анкет лежат на системном диске в открытом виде.

Общий разбор того, как systemd зависит от mount-юнитов, у меня уже есть: в статье про службу, требующую смонтированный том я показывал базовую связку RequiresMountsFor, BindsTo и запрет записи. Здесь другой угол. Я не пересказываю схему, а сравниваю механизмы между собой: что каждый из них ловит, что молча пропускает и в каком порядке их стоит включать, если времени мало, а сервер уже в продакшене. Это материал для тех, кто стоит перед выбором, а не перед пустой страницей конфига.

Начну с неприятной правды. Существующий каталог ничего не говорит о хранилище. Если том не смонтирован, /srv/vault остаётся обычной папкой на корневом разделе, и служба с правами на запись спокойно туда пишет. Потом том подключают, он закрывает папку сверху, и данные визуально исчезают, хотя место на системном диске по-прежнему занято. Самое смешное, что в журнале при этом нет ни одной ошибки: с точки зрения программы всё прошло успешно. Мониторинг при этом зелёный, пока корень не забьётся до отказа.

Отсюда и порядок работы. Сначала выясняем, какие службы вообще пишут в пути, которые должны жить на отдельном томе: смотрим WorkingDirectory, пути в конфигах и каталоги, указанные в параметрах запуска. Затем для каждой службы решаем, что должно случиться при пропаже тома: остановка с шумом или тихий пропуск. И только потом правим юниты. Делать наоборот, то есть сначала копировать чужой drop-in из интернета, а потом гадать, почему служба не стартует после перезагрузки, я не советую: такие правки ломают запуск чаще, чем защищают данные.

Requires, Condition, Assert, BindsTo: что ловит каждый механизм

Идём по документации systemd.unit. RequiresMountsFor= принимает список абсолютных путей и автоматически добавляет зависимости Requires= и After= для всех mount-юнитов, нужных для доступа к этим путям. То есть при запуске службы systemd сам попытается смонтировать том, а если не выйдет, служба не стартует. Это ответ на первый вопрос. На второй он не отвечает: если том отвалился позже, зависимость запуска сама по себе службу не остановит. Для этого есть BindsTo=: он работает как Requires=, но сильнее, потому что останавливает вашу службу, когда указанный юнит неожиданно останавливается, включая случай сбоя. Парой к нему всегда идёт After=, иначе порядок запуска не определён.

Теперь про проверки. ConditionPathIsMountPoint= проверяет, что путь существует и является точкой монтирования. Но у условий особая семантика: если Condition не выполнено, запуск, по словам документации, «в основном молча» пропускается, а сам юнит в состояние failed не переводится. Для службы, которая должна громко упасть и разбудить дежурного, это плохой вариант. Рядом лежит AssertPathIsMountPoint=: те же проверки, но невыполненное утверждение приводит к ошибке задачи запуска вместо пропуска, а сама неудачная проверка записывается в журнал. Юнит при этом, как сказано в документации, тоже не переводится в failed: ошибку получает именно job, и systemctl start вернёт ненулевой код. Моё правило простое: для защиты данных беру Assert, а Condition оставляю для необязательных служб, которым нормально не запуститься.

Есть ещё самодельный вариант: ExecStartPre=/usr/bin/mountpoint -q /srv/vault. Команда mountpoint по документации util-linux возвращает 0, если каталог является точкой монтирования, и 32, если нет. Если команда в ExecStartPre= завершилась ошибкой (без префикса «-»), остальные команды не выполняются и юнит считается неудачным. Это даёт громкое падение, но работает только в момент старта. Как и все проверки запуска, оно ничего не знает о томе, который отвалится через три часа. Поэтому мой минимальный набор такой: RequiresMountsFor плюс BindsTo плюс After плюс AssertPathIsMountPoint, а ExecStartPre оставляю только на системах, где нужна нестандартная проверка.

Условие и утверждение выглядят похоже, но ведут себя по-разному: невыполненный Condition тихо пропускает запуск, а невыполненный Assert даёт ошибку задачи. Для защиты данных нужна ошибка, а не тишина.
Таблица: RequiresMountsFor, BindsTo, Assert, Condition и chattr +i при привязке службы Linux к тому
Нужна громкая ошибка, а не тишина.

Готовый drop-in и fstab для службы с отдельным томом

Покажу конфигурацию целиком, чтобы можно было взять и проверить на тестовой машине. Сначала том. Точку монтирования создаём на корневом разделе и делаем её непригодной для записи, об этом следующий раздел. В /etc/fstab том описываем по UUID, а не по имени устройства, потому что имена вида /dev/sdb при смене порядка дисков меняются. Опция noauto по документации systemd.mount убирает mount-юнит из зависимостей local-fs.target, так что загрузка не ждёт зашифрованный том; nofail здесь страховка на случай, если noauto кто-то потом уберёт: тогда том будет только желателен (Wants), а не обязателен. При этом RequiresMountsFor= по документации учитывает и noauto-точки, то есть служба сама подтянет mount-юнит. x-systemd.device-timeout ограничивает, сколько systemd ждёт появления устройства, и работает только в /etc/fstab. Имя mount-юнита получаем командой systemd-escape: для /srv/vault это srv-vault.mount.

blkid /dev/mapper/vault
systemd-escape --path --suffix=mount /srv/vault
# строка в /etc/fstab (UUID подставьте свой):
# UUID=<uuid-из-blkid> /srv/vault ext4 noauto,nofail,x-systemd.device-timeout=30s 0 2
systemctl daemon-reload

Теперь сама служба. Правим не основной файл юнита, а drop-in, чтобы обновление пакета не затёрло настройки. Выполняем systemctl edit vault-sync.service и вставляем блоки ниже. Обратите внимание на ReadWritePaths= без дефиса в начале: по документации systemd.exec префикс «-» заставляет игнорировать несуществующий путь, а мне как раз нужно обратное, чтобы служба не запускалась в песочнице, где записываемого каталога нет. На практике без дефиса отсутствующий путь ломает подготовку пространства имён, и служба падает со статусом 226/NAMESPACE: если том не смонтирован и каталога spool нет, это ещё один громкий отказ. Точный текст ошибки в журнале проверьте на своей версии systemd.

[Unit]
RequiresMountsFor=/srv/vault
BindsTo=srv-vault.mount
After=srv-vault.mount
AssertPathIsMountPoint=/srv/vault

[Service]
User=vaultsync
Group=vaultsync
ProtectSystem=strict
ReadWritePaths=/srv/vault/spool
NoNewPrivileges=yes

Отдельно про ProtectSystem=strict. Документация говорит, что при этом значении вся иерархия файловой системы монтируется только для чтения, кроме поддеревьев API-файловых систем /dev, /proc и /sys. Записываемые места вы перечисляете в ReadWritePaths=. Но есть оговорка, которую многие упускают: ограничения этих директив не распространяются на подмонтирования каталога, созданные позже. Поэтому песочница не заменяет зависимость от mount-юнита, а дополняет её. Если вы рассчитывали, что ProtectSystem сам разберётся с отсутствующим томом, лучше пересмотреть ожидания.

Последний рубеж: chattr +i на пустой точке монтирования

Все директивы выше живут в конфиге службы. А что, если завтра кто-то запустит скрипт руками под root, добавит ещё одну службу без защиты или заведёт контейнер, который пишет в тот же путь? Защита должна жить и на стороне каталога. Классический ответ даёт режим 000, и его используют многие, в том числе я в более ранних проектах. Но у него есть слабое место: root с capability CAP_DAC_OVERRIDE игнорирует права доступа, и всё, что запущено от root без песочницы, будет писать мимо тома как ни в чём не бывало. Меня эта тема раздражает отдельно: «каталог закрыт на 000» годами считается надёжной защитой, хотя от root она не защищает вообще.

Атрибут immutable устроен жёстче. Согласно документации chattr, файл с атрибутом i нельзя изменять, удалять или переименовывать, а установить или снять его может только суперпользователь или процесс с capability CAP_LINUX_IMMUTABLE. На каталоге это означает, что в нём нельзя создавать новые записи. Порядок такой: размонтировать том, убедиться, что каталог пуст, и поставить атрибут. После монтирования том закрывает каталог сверху, и служба работает на смонтированной файловой системе, где этого атрибута нет. Если же том не смонтирован, любая попытка создать файл в пустой точке заканчивается отказом, даже у root.

umount /srv/vault 2>/dev/null
ls -A /srv/vault
chattr +i /srv/vault
lsattr -d /srv/vault
mount /srv/vault

Обратная сторона честная: атрибут надо снимать при смене структуры каталога, а команды вроде rm -rf на такой точке будут завершаться отказом, что некоторых админов пугает. Ещё нужно помнить, что поддержка атрибута зависит от файловой системы, поэтому проверяйте lsattr на своём корне. Сам по себе chattr +i службу не останавливает, он лишь делает ошибку шумной: вместо тихой записи мимо получаете отказ в журнале. Именно поэтому он идёт после зависимостей, а не вместо них.

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

Шкала выходных: 59 часов без тома, 3,1 ГБ в корень и результат после привязки службы Linux к тому
Одна перезагрузка: 3,1 ГБ мимо тома.

Разбор: «Душевный баланс», 50 рабочих мест и зашифрованный том

Условный клиент для разбора: центр психологического консультирования «Душевный баланс», 50 рабочих мест. Сервер на Ubuntu Server 24.04 LTS с systemd 255, корень 40 ГБ, отдельный том на 500 ГБ поверх LVM под LUKS, смонтированный в /srv/vault. Служба vault-sync раз в 15 минут забирала сканы анкет и согласий с сетевого МФУ регистратуры и складывала их в том. Данные чувствительные, поэтому том шифрован, и после перезагрузки его разблокирует администратор вручную. Как устроено шифрование дисков, я описывал в статье про шифрование диска через LUKS, здесь важен только вывод: после рестарта сервера тома нет, пока человек не введёт ключ.

Что случилось. В пятницу в 21:40 сервер перезагрузили после обновлений. Том разблокировали в понедельник в 08:50, почти через 59 часов. Служба в субботу и воскресенье стартовала сама, каталог /srv/vault существовал, права на запись были, и все сканы за выходные, около 3,1 ГБ, ушли на корневой раздел в открытом виде. В понедельник том подключили, и папка с данными стала выглядеть пустой. Заметили это только потому, что в бухгалтерии спросили про пропавшие за субботу согласия. Моя ирония здесь уместна ровно в одном месте: шифрованный том, который стоил денег и нервов, защищал данные ровно до первой перезагрузки.

Что сделали мы. Вынесли конфигурацию в drop-in из третьего раздела: RequiresMountsFor, BindsTo, After и AssertPathIsMountPoint, пользователь vaultsync вместо root, ProtectSystem=strict с единственным записываемым каталогом. На размонтированной точке поставили chattr +i. Работа заняла около трёх часов вместе с тестом на клоне виртуальной машины: перенос найденных 3,1 ГБ с корня на том и их удаление с корня я не описываю, это отдельная процедура, и я разбирал её в статье про службу, которая пишет на системный диск. После правки при перезагрузке без разблокировки тома служба не стартует и оставляет в журнале понятную ошибку.

Итог за 45 дней наблюдения: под точкой монтирования пусто, корень держится около 31 процента, а в графике свободного места на корне больше нет ступенек по выходным. Мы также договорились о том, как поступать после перезагрузки. Служба не запускается сама, пока администратор не разблокировал том и не запустил её командой. Это сознательное решение: автоматический перезапуск при появлении диска мог бы незаметно поднять обработку очереди, накопившейся за выходные, поэтому её запускает человек.

Чек-лист проверки привязки службы Linux к тому: umount, отказ старта, отказ записи и мониторинг монтирования
Готово только после проверочного размонтирования.

Как проверить результат и что поставить на мониторинг

Проверка делается на тестовой копии, а не на живом сервере. Тест из трёх действий. Первое: убедиться, что systemd видит зависимости. Команда systemctl show -p RequiresMountsFor -p BindsTo -p After vault-sync.service должна показать ваш путь и mount-юнит. Второе: размонтировать том и попробовать запустить службу, ожидая отказ. Третье: запустить службу при смонтированном томе, а затем размонтировать его на лету и посмотреть, что служба остановилась. Учтите, что umount при открытых службой файлах завершится ошибкой «target is busy»; надёжнее остановить mount-юнит командой systemctl stop srv-vault.mount, и тогда systemd по зависимостям сначала остановит службу. Для проверки точки монтирования удобен findmnt: ключ -M принимает точный путь точки монтирования, -n убирает заголовок, а код возврата 1 означает, что ничего не найдено.

systemctl show -p RequiresMountsFor -p BindsTo -p After vault-sync.service
umount /srv/vault
systemctl start vault-sync.service; echo $?
journalctl -u vault-sync.service --since -10min
touch /srv/vault/test; echo $?
mount /srv/vault
findmnt -n -M /srv/vault -o TARGET,SOURCE,FSTYPE

Теперь о том, что проверка запуска не заменяет мониторинг. Эти настройки не подтверждают исправность операций ввода-вывода: активное монтирование и доступность данных нужно различать. Том может быть смонтирован, но переведён в режим только для чтения после ошибки файловой системы, и systemd будет считать, что всё в порядке. Я ставлю на мониторинг три вещи: наличие точки монтирования (код возврата findmnt), заполнение корневого раздела и заполнение самого тома. В Zabbix для корня есть стандартный ключ vfs.fs.size[/,pused], а для проверки монтирования подойдёт пользовательский параметр, возвращающий результат findmnt.

Перед тем как отметить задачу закрытой, я задаю себе четыре вопроса. Что будет, если том не появится при загрузке? Что будет, если он отвалится в середине работы? Что будет, если кто-то запустит программу руками под root? И придёт ли кому-нибудь уведомление об этом? Если хотя бы на один ответ звучит «ну, наверное, ничего страшного», настройка не готова. Бэкап делают все, а вот проверяет его почти никто. С монтированием та же история.

Частые вопросы

Чем RequiresMountsFor отличается от BindsTo?

RequiresMountsFor добавляет Requires и After для всех mount-юнитов, нужных для доступа к указанным путям, и отвечает за запуск. BindsTo дополнительно останавливает службу, если указанный юнит неожиданно остановился, включая сбой.

Что лучше: ConditionPathIsMountPoint или AssertPathIsMountPoint?

Для защиты данных лучше Assert: невыполненное утверждение даёт ошибку задачи запуска и запись в журнале. Невыполненный Condition тихо пропускает старт и не переводит юнит в состояние failed.

Защитит ли chmod 000 на пустой точке монтирования?

От обычных пользователей да, но не от процесса root с правом обхода прав доступа. Атрибут chattr +i строже: в таком каталоге нельзя создавать записи, пока он не снят суперпользователем.

Перезапустится ли служба сама после возвращения тома?

По документации Restart= не срабатывает, когда процесс остановлен операцией systemd, например командой stop. Поведение при остановке через BindsTo проверьте на своей версии, и не рассчитывайте на автоматический запуск без теста.

Достаточно ли одной проверки при запуске службы?

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

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи