systems · intermediate · 7d
Сервис, которому доверяет systemd
Скрипт, который запускается, может написать кто угодно. Задача — превратить его в сервис, который операционная система будет поддерживать живым, разумно перезапускать, честно логировать и зажмёт в угол, если он начнёт сбоить. Ты проведёшь небольшой демон от «работает, пока я держу терминал открытым» до настоящего systemd-юнита — запускаемого при загрузке, под надзором PID 1, с логами в journald и ограниченным радиусом поражения. Это и есть разница между кодом и сервисом.
Результат
Долгоживущий демон, установленный как systemd-сервис, который запускается при загрузке, перезапускается после падения с задержкой, шлёт структурированные логи в journald, работает в рамках бюджета по памяти и CPU, отдаёт сигнал здоровья, перечитывает конфиг без полного перезапуска и изолирован через NoNewPrivileges и ProtectSystem.
Этапы
0/5 · 0%- 01Демон, который ведёт себя правильно
Напиши самую маленькую программу, которую стоит держать под надзором: процесс, который остаётся на переднем плане, делает крошечную порцию работы в цикле и пишет строку вывода на каждом проходе. Инстинкт из мира скриптов — уйти в фон и отделиться от терминала — подави его. Под systemd правильная форма обратная: оставайся прикреплённым, отдай управление жизненным циклом init-системе и пиши логи в stdout/stderr, а не открывай собственный файл. Затем докажи то, что все пропускают: перехвати SIGTERM и завершись чисто. Сервис, игнорирующий SIGTERM, получает SIGKILL по истечении таймаута остановки и теряет всё, что делал на полпути. Сделай завершение осознанным, обработанным событием, а не гильотиной.
Критерии готовности- Демон работает на переднем плане, крутит цикл с работой и печатает строку в stdout на каждом проходе (без ухода в фон, без собственного файла логов).
- Отправка SIGTERM заставляет его завершить текущий шаг, напечатать строку о завершении и выйти с кодом 0 в течение пары секунд.
- 02Оберни его в юнит
Теперь передай демон systemd. Напиши unit-файл в /etc/systemd/system, опиши секции [Unit], [Service] и [Install], направь ExecStart на свой бинарь и освой танец daemon-reload / enable / start, на котором спотыкаются все в первый раз: systemd кэширует unit-файлы, и правки ничего не дают, пока ты не сделаешь reload. Выбери правильный Type сервиса: обычный цикл на переднем плане — это Type=simple, но как только захочешь, чтобы systemd знал, что сервис действительно готов (а не просто запущен), потянешься к Type=notify и сигналу готовности. Задай политику Restart и RestartSec, затем убей процесс руками и смотри, как PID 1 возвращает его обратно. В этом главное обещание init-системы: падение — не конец, а событие с заданной реакцией.
Критерии готовности- systemctl enable --now запускает сервис, и он автоматически возвращается после перезагрузки.
- Убийство процесса (например, kill -9 по PID) запускает перезапуск согласно политике Restart=, что видно в systemctl status.
- 03Логи, которые реально можно читать
Поскольку демон пишет в stdout и работает под systemd, journald уже захватывает каждую строку — бесплатно, без файла логов, без cron-ротации, которую нужно помнить. Посвяти этот этап тому, чтобы допрашивать этот поток как оператор: journalctl -u your-service, -f для слежения в реальном времени, --since для окна, -p err для фильтра по приоритету и -o json, чтобы увидеть структурированные поля, которые приклеил journald. Затем сделай вывод достойным фильтрации: проставляй приоритет на важных строках (warning, когда шаг работы провалился, error, когда восстановиться нельзя), чтобы -p warning выдавал сигнал, а не шум. Цель в том, чтобы, когда сервис засбоит в три ночи, один вызов journalctl рассказал дежурному, что произошло, по порядку, с метками времени, которым можно верить.
Критерии готовности- journalctl -u <service> показывает вывод демона, а -f следит за ним вживую, пока сервис работает.
- Хотя бы одна строка лога несёт не-дефолтный приоритет, так что journalctl -p warning (или выше) отфильтровывает только значимые события.
- 04Бюджет, здоровье и мягкая перезагрузка
Сервис без границ — это будущий инцидент. Ограничь его: задай MemoryMax и CPUQuota в юните, чтобы утечка или зациклившийся процесс был придушен или убит по OOM собственной cgroup, а не утянул за собой всю машину, — затем намеренно пробей лимит памяти и смотри, как решение принимает cgroup, а не глобальный OOM-killer ядра. Дальше дай systemd настоящее определение «здоровья»: переключись на Type=notify, отправь сигнал готовности, когда запуск действительно завершён, и при желании взведи WatchdogSec, чтобы зависший демон, переставший пинговать, перезапускался, а не висел живым, но бесполезным. Наконец, проложи путь перезагрузки — ExecReload плюс обработчик SIGHUP, — чтобы менять конфиг и перечитывать его, не убивая процесс. Урок в том, что «запущен» и «работает» — разные состояния, и управляемый сервис обязан доказать второе.
Критерии готовности- MemoryMax/CPUQuota заданы и проверяемы: если выгнать демон за лимит памяти, его убивает собственная cgroup, что видно в systemctl status / journalctl.
- systemctl reload перечитывает конфиг без перезапуска (PID не меняется), и новый конфиг заметно вступает в силу.
- 05Зажми его в коробку и поставь на часы
Сервис — это ещё и поверхность атаки, а в systemd встроен набор «запрещено по умолчанию», который почти никто не включает. Закали его: NoNewPrivileges=true, чтобы взлом не мог повыситься через setuid-бинарники, ProtectSystem=strict, чтобы вся файловая система стала только для чтения, кроме путей, которые ты явно разрешил, PrivateTmp=true для изолированного /tmp и узкий ReadWritePaths для единственной директории, которая ему действительно нужна. Запусти systemd-analyze security на своём юните и смотри, как падает оценка уязвимости с каждой добавленной директивой, — относись к этому как к чек-листу с цифрой. Затем добавь второй артефакт: периодическую задачу. Напиши маленький сопутствующий юнит и .timer, который запускает его по расписанию (скажем, ночную чистку или снимок здоровья), и пойми, почему systemd-таймер здесь лучше строчки cron — он наследует тот же sandbox, пишет в тот же журнал и переживает пропущенные запуски через Persistent=true. К финалу у тебя сервис под надзором, наблюдаемый, ограниченный, изолированный и по расписанию — полная форма production-юнита.
Критерии готовности- Юнит задаёт NoNewPrivileges, ProtectSystem=strict (или full) и минимальный ReadWritePaths; сервис по-прежнему работает, а systemd-analyze security показывает улучшенную оценку.
- Юнит .timer запускает сопутствующую задачу по расписанию, что видно в systemctl list-timers и в журнале с её собственными записями.
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Корректность юнита и тип сервиса | Unit-файл существует и `systemctl start` запускает демон. Тип сервиса — Type=simple (или не задан) независимо от того, сигнализирует ли процесс о готовности. Демон может уходить в фон, что приводит к некорректному отслеживанию PID в systemd. | Тип сервиса выбран в соответствии с поведением демона: Type=simple для цикла с немедленной готовностью, Type=notify когда процесс сигнализирует о готовности через sd_notify(). Демон остаётся на переднем плане, пишет только в stdout/stderr и завершается с кодом 0 по SIGTERM в рамках таймаута остановки. `systemctl enable --now` работает после перезагрузки. | Ты объясняешь разницу между «запущен» и «готов»: сервис Type=simple помечается активным в момент старта процесса, поэтому зависимость, стартующая после него, может попытаться подключиться до того, как он реально слушает. Type=notify с WatchdogSec делает этот разрыв наблюдаемым: если процесс зависает после старта, PID 1 это обнаруживает и перезапускает. Ты можешь проследить, что делает systemd при истечении WatchdogSec на зависшем процессе. |
| Политика перезапуска и бюджет ресурсов | Задан Restart=always. RestartSec задержка не настроена — сервис будет перезапускаться немедленно в тесном цикле при повторных падениях, потенциально перегружая систему. MemoryMax и CPUQuota отсутствуют. | Политика перезапуска задана с задержкой RestartSec (например, RestartSec=5). MemoryMax и CPUQuota заданы и проверены: намеренный выход за лимит памяти завершает процесс cgroup (видно в systemctl status и journalctl), а не глобальным OOM-киллером ядра. Намеренный kill -9 запускает перезапуск, видимый в systemctl status. | Ты объясняешь преимущество завершения через cgroup перед глобальным OOM-киллером: OOM cgroup ограничен сервисом и не создаёт давления по памяти на весь хост, тогда как глобальное OOM-событие эвристически выбирает жертв и может убить несвязанный высокоприоритетный процесс. Ты рассуждаешь о StartLimitIntervalSec и StartLimitBurst для предотвращения поглощения всего внимания PID 1 зациклившимся в падениях сервисом во время шторма отказов. |
| Наблюдаемость через journald | Демон пишет в stdout, и логи появляются в `journalctl -u <service>`. Весь вывод на одном уровне приоритета — нет возможности отфильтровать предупреждения от информационных строк. | Хотя бы одно значимое событие (проваленный шаг работы, предупреждение при старте) несёт не-дефолтный приоритет, так что `journalctl -p warning` отфильтровывает сигнал. Оператор знает, как использовать --since, -f и -o json для ограничения, слежения и машинного разбора потока логов. | Ты рассуждаешь о структурированном логировании: если демон выводит пары key=value (или JSON) в stdout, journald хранит их как индексированные поля, по которым можно делать запросы через `journalctl FIELD=value`. Ты описываешь, как выглядит вызов дежурного в три ночи: точный вызов journalctl, выводящий последнюю ошибку этого сервиса с момента предыдущего успешного запуска, и какой информации не хватает в неструктурированной строке лога, что потребовало бы её разбора. |
| Директивы sandbox и ограничение радиуса поражения | Юнит работает от root или от пользователя сервиса без директив sandbox. systemd-analyze security сообщает высокую оценку уязвимости. NoNewPrivileges отсутствует — setuid-бинарь, достижимый из сервиса, мог бы повысить привилегии. | Заданы NoNewPrivileges=true, ProtectSystem=strict и PrivateTmp=true. ReadWritePaths ограничен одной директорией, которая сервису действительно нужна. Сервис по-прежнему стартует и работает после применения sandbox. systemd-analyze security показывает существенно улучшенную оценку по сравнению с базовой. | Ты рассуждаешь о возможностях атакующего после компрометации: при NoNewPrivileges процесс не может повысить привилегии через setuid-бинарь; при ProtectSystem=strict он не может писать в системные пути или подбросить замену бинаря; при PrivateTmp поверхность атаки через /tmp (гонки временных файлов, общие секреты) устраняется. Ты определяешь, что сервис по-прежнему может делать — писать в свою единственную директорию ReadWritePaths и совершать исходящие сетевые вызовы, — и называешь следующие директивы (RestrictAddressFamilies, IPAddressAllow), которые дополнительно сократили бы эти возможности. |
Эталонный разбор (спойлер)
Type=notify против Type=simple: при Type=simple systemd помечает сервис активным в момент fork ExecStart, поэтому зависимый юнит может стартовать до того, как сервис реально готов принимать соединения. Type=notify откладывает переход в активное состояние до вызова процессом sd_notify(READY=1), делая готовность явным протоколом, а не предположением о тайминге. Цена — процесс должен линковаться с libsystemd или реализовывать сокетный протокол sd_notify.
Ограничения ресурсов cgroup против глобального OOM: MemoryMax запускает OOM на уровне cgroup, убивающий только процесс, превысивший бюджет в этом сервисе, не трогая остальное на системе. Глобальный OOM-киллер — крайняя мера, эвристически убивающая процессы по всему хосту и способная выбрать жертву, не связанную с утекающим по памяти сервисом. Явные бюджеты cgroup перехватывают утечки до того, как они становятся хостовыми инцидентами.
ProtectSystem=strict устраняет целый класс постэксплуатационного персистирования: при файловой системе только для чтения (за исключением явного ReadWritePaths) скомпрометированный процесс не может записать бэкдор в системный путь, заменить собственный бинарь или подбросить задание cron. Радиус поражения при компрометации ограничен тем, что процесс может сделать в памяти и в единственной доступной для записи директории.
Таймеры systemd против cron: юнит .timer наследует тот же юнит [Service], включая его User=, директивы sandbox, лимиты cgroup и логирование в journald. Задание cron выполняется от root (или владельца crontab) в голой оболочке без бюджета ресурсов, без sandbox и с выводом, который обычно исчезает без явного перенаправления. Наследование делает таймер systemd production-grade обёрткой вокруг того же скрипта, который был бы хрупким под cron.
Сделай по-сеньорски
- Запусти демон под выделенным непривилегированным пользователем через DynamicUser=true, позволив systemd выдать одноразовый UID на время работы и автоматически вычистить его каталог состояния, — затем проверь, что он не может прочитать файлы другого сервиса.
- Добавь drop-in-переопределение (systemctl edit) вместо правки самого юнита, чтобы твои настройки ресурсов и защиты пережили обновление пакета, которое заменит исходный unit-файл.
- Ужесточи sandbox дальше через SystemCallFilter (например, @system-service) и CapabilityBoundingSet, затем перезапусти systemd-analyze security и объясни, какая директива купила какое снижение уязвимости.