Иногда systemd говорит: «всё отлично, сервис запущен», а мониторинг и ss -lntp отвечают обратным: порт не слушает. Обычно причина приземлённая: упал ExecStartPre, неверно выбран Type, не хватает прав на привилегированный порт или слишком жёсткое systemd sandboxing отрезало процессу доступ к файлам, сокетам или сети.
Ниже — практический чек‑лист для админов/DevOps: как читать логи через journalctl, как «раскрыть» итоговую конфигурацию unit-файла и как аккуратно настраивать AmbientCapabilities и sandboxing так, чтобы безопасность росла, а сервис не ломался.
Быстрый триаж: жив ли сервис и что именно не так
Начинайте с простого: systemd может считать сервис «активным», даже если приложение внутри уже упало или завершилось слишком быстро (часто из-за неверного Type).
1) Статус юнита и последние ошибки
systemctl status myapp.service
systemctl show myapp.service -p ExecMainStatus -p ExecMainCode -p MainPID -p Result
systemctl is-failed myapp.service
Если Result не success, systemd обычно пишет конкретную причину. Если MainPID пустой — разберитесь с Type и поведением процесса: возможно, приложение завершилось или юнит вообще не держит процесс «на привязи».
2) Логи через journalctl: точечно и без лишнего шума
journalctl -u myapp.service -b --no-pager
journalctl -u myapp.service -b -n 200 --no-pager
journalctl -u myapp.service -b -p warning..alert --no-pager
Для кейса «порт не слушает» важны первые секунды старта: ошибки биндинга, падение конфиг‑теста, запреты sandboxing, проблемы с правами на файлы и каталоги.
3) Проверка, что порт действительно не открыт (и кем должен быть открыт)
ss -lntp
ss -lntp '( sport = :80 )'
Если порт пустой — выясняем, почему процесс не дошёл до этапа биндинга. Если порт занят другим процессом — это конфликт, а не «не слушает».
Unit debug: увидеть реальную итоговую конфигурацию запуска
Сложность unit-файлов в том, что вы смотрите на ExecStart, а реально запускается цепочка: зависимости, ExecStartPre, ограничения безопасности, окружение, рабочая директория. В отладке помогает «раскрыть» итоговую конфигурацию.
Показать юнит полностью (с drop-in) и найти переопределения
systemctl cat myapp.service
systemctl show myapp.service --no-pager
systemctl show myapp.service -p FragmentPath -p DropInPaths
Частая причина «странностей» — override в /etc/systemd/system/myapp.service.d/override.conf, который когда-то добавили «на время», а он остался.
Проверить синтаксис и ошибки unit-файлов
systemd-analyze verify /etc/systemd/system/myapp.service
Это не панацея, но ловит часть проблем: опечатки, неверные секции, несуществующие директивы (особенно если переносили юнит между версиями systemd).
Важно: systemd не использует shell по умолчанию
Конструкции вида ExecStart=cd /app && ./server не «магически» выполняются в оболочке: systemd запускает бинарник напрямую. Если нужна оболочка — указывайте её явно, но чаще правильнее разложить логику на WorkingDirectory и отдельные простые ExecStartPre.
ExecStartPre: типичные ловушки и быстрая диагностика
ExecStartPre полезен (миграции, проверка конфигов, подготовка каталогов), но именно он часто делает старт «неочевидным»: человек видит «что-то выполнилось», а systemd уже остановил запуск до ExecStart.

Как systemd обрабатывает ExecStartPre
- Каждый
ExecStartPreзапускается по очереди. - Если любой
ExecStartPreзавершился с ненулевым кодом — старт сервиса считается проваленным,ExecStartне выполняется. - Префикс
-перед командой позволяет игнорировать ошибку, но используйте это только осознанно (иначе легко замаскировать реальную причину).
Понять, какой pre-скрипт упал и почему
systemctl status myapp.service
journalctl -u myapp.service -b -n 200 --no-pager
В логах ищите строку вида «Running ExecStartPre=…». Если команда сложная — вынесите её в отдельный скрипт: так проще контролировать логирование и exit code.
Примеры ExecStartPre без «магии» shell
Вместо «cd и запусти» используйте нативные директивы:
[Service]
WorkingDirectory=/opt/myapp
ExecStartPre=/usr/bin/test -r /opt/myapp/config.yml
ExecStartPre=/usr/bin/env
ExecStart=/opt/myapp/myapp --config /opt/myapp/config.yml
/usr/bin/env как временный ExecStartPre помогает увидеть окружение, с которым стартует сервис (после отладки лучше убрать, чтобы не засорять журнал).
Кейс «порт не слушает» из-за ExecStartPre
Типовой сценарий: ExecStartPre делает конфиг‑тест, но использует относительные пути или ожидает файл в другом месте. Вручную вы запускали из каталога проекта — всё работало. В systemd рабочий каталог другой — precheck падает, и до биндинга портов процесс не доходит.
Решение: задайте WorkingDirectory и используйте абсолютные пути для всех файлов.
Когда сервис «active», но порт не слушает: причины, которые реально встречаются
1) Неверный Type и «успешный» выход процесса
Если демон форкается в фоне, а вы оставили Type=simple, systemd может «потерять» главный процесс. Если демон не форкается, а вы указали Type=forking, systemd будет ждать форк/PID-файл и старт станет «странным».
Для современных приложений чаще подходят Type=simple или Type=notify (если поддерживается systemd-notify). И по возможности избегайте демонизации внутри приложения, если им управляет systemd.
2) Приложение слушает не тот адрес
«Порт не слушает» нередко означает «слушает только localhost». Проверьте конфиг приложения: 127.0.0.1:8080 вместо 0.0.0.0:8080 или вместо нужного IPv6. systemd тут ни при чём, но вы поймаете это на этапе unit debug.
3) Конфликт порта или привязка к 80/443 без прав
Если сервис должен слушать 80/443, а вы запускаете его не от root, нужно право CAP_NET_BIND_SERVICE (или фронтовой прокси перед приложением). Ниже покажу, как выдать capability точечно.
4) Sandboxing отрезал доступ к сокетам/файлам/каталогам
Сервис может стартовать, но не создать Unix-сокет, не открыть конфиг, не записать PID/лог, не прочитать сертификаты — в итоге приложение уходит в ошибку или стартует «в деградированном режиме» без слушающего сокета.
5) Неправильные права на каталоги RuntimeDirectory/StateDirectory
Если вы пишете PID-файл, сокет или временные файлы, лучше использовать RuntimeDirectory, StateDirectory, CacheDirectory, а не изобретать ручные mkdir в ExecStartPre. Так меньше гонок, меньше проблем с правами и понятнее модель владения.
Capabilities: как безопасно дать сервису право слушать 80/443
Когда нужен привилегированный порт, многие по привычке запускают сервис от root. Под systemd обычно лучше иначе: запускать от отдельного пользователя и выдать минимально необходимую capability.
AmbientCapabilities и CapabilityBoundingSet: разница по смыслу
AmbientCapabilities добавляет capability в ambient set, чтобы она наследовалась исполняемым процессом.
CapabilityBoundingSet ограничивает максимум того, что вообще может быть у процесса. Практика простая: bounding set делайте минимальным, а в ambient добавляйте только то, что действительно нужно.
Пример: слушаем 80, но остаёмся не-root
[Service]
User=myapp
Group=myapp
ExecStart=/opt/myapp/myapp --listen :80
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
Если после этого всё равно «порт не слушает», вернитесь к journalctl: часто приложение падает до биндинга по другой причине, а capability оказалась просто «красивой версией» проблемы.
Проверка capabilities у процесса
systemctl show myapp.service -p MainPID
cat /proc/$(systemctl show myapp.service -p MainPID --value)/status | grep Cap
Если MainPID пустой — сначала разберитесь с типом/стартом, иначе проверять нечего.
systemd sandboxing: как включать жёсткость без самострела
Под systemd sandboxing обычно подразумевают набор директив, которые уменьшают поверхность атаки: изоляция файловой системы, запрет опасных системных вызовов, ограничение сети, запрет повышения привилегий и т.д.
Включайте sandboxing итеративно: добавили одну группу директив, перезапустили, посмотрели
journalctl. Если включить всё сразу, вы получите «порт не слушает» и несколько часов охоты на одну-единственную настройку.

Базовый «разумный минимум» для сетевого демона
[Service]
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ProtectControlGroups=true
ProtectKernelTunables=true
ProtectKernelModules=true
LockPersonality=true
RestrictSUIDSGID=true
RestrictRealtime=true
MemoryDenyWriteExecute=true
RuntimeDirectory=myapp
StateDirectory=myapp
ReadWritePaths=/var/lib/myapp /run/myapp
Ключевой момент: если включили ProtectSystem=strict, почти наверняка нужно описать, куда сервису можно писать. Делайте это через StateDirectory/RuntimeDirectory и точечные ReadWritePaths.
SystemCallFilter и «внезапно перестало работать»
SystemCallFilter полезен, но это самая «хрупкая» часть. Неправильный фильтр может приводить к падениям библиотек, TLS-стека, рантаймов языков. На практике часто больший эффект дают более простые директивы: NoNewPrivileges, ProtectSystem, PrivateTmp, ProtectHome, RestrictSUIDSGID.
RestrictAddressFamilies: сеть «вроде есть», но сокет не создаётся
Если вы ограничили адресные семейства, приложение может не создать IPv6-сокет или Unix-сокет — и в итоге не поднять слушатель.
[Service]
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
Если используете только IPv4, можно убрать AF_INET6, но сначала убедитесь, что приложение/библиотеки не рассчитывают на dual-stack.
PrivateDevices и доступ к системным ресурсам
Некоторые демоны читают системную информацию (например, для метрик) и могут ломаться при слишком жёстких ограничениях устройств и псевдо‑файловых систем. Если вы включили PrivateDevices=true и поведение стало странным — это хороший кандидат на временное отключение для подтверждения гипотезы.
Практический шаблон юнита: подготовка, capabilities, sandboxing и понятные логи
Ниже — скелет unit-файла, который удобно отлаживать. Пути и команды подставьте свои.
[Unit]
Description=MyApp HTTP service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStartPre=/usr/bin/test -r /opt/myapp/config.yml
ExecStart=/opt/myapp/myapp --config /opt/myapp/config.yml --listen :80
Restart=on-failure
RestartSec=2
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
RuntimeDirectory=myapp
StateDirectory=myapp
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/lib/myapp /run/myapp
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Методика «включаем по одному»: быстро найти директиву, которая всё сломала
Если вы усилили юнит и получили «не стартует» или «порт не слушает», не откатывайте всё сразу. Работает метод двоичного поиска, но обычно хватает итераций по смысловым группам.
- Сначала временно уберите sandboxing (жёсткие директивы), оставив
User,WorkingDirectory,ExecStart,ExecStartPre. - Если заработало — возвращайте директивы пачками: файловая система (ProtectSystem/ReadWritePaths), затем tmp/devices, затем capabilities/NoNewPrivileges, затем address families.
- После каждого изменения делайте reload и перезапуск.
systemctl daemon-reload
systemctl restart myapp.service
systemctl status myapp.service
journalctl -u myapp.service -b -n 200 --no-pager
При отладке полезно временно поставить Restart=no, чтобы сервис не уходил в цикл рестартов и не перемешивал логи.
Частые сообщения в journalctl и что они обычно означают
Permission denied при bind на 80/443
Обычно это отсутствие CAP_NET_BIND_SERVICE при запуске не от root. Исправляется через AmbientCapabilities или переносом прослушивания на непривилегированный порт с проксированием.
No such file or directory на ExecStartPre/ExecStart
Либо путь неверный, либо вы полагаетесь на PATH (в unit он может быть минимальным), либо бинарник не исполняемый. Указывайте абсолютные пути.
Read-only file system / Operation not permitted после ProtectSystem
Сервис пытается писать туда, куда теперь нельзя. Решение: писать в каталоги, которые создал systemd (StateDirectory/RuntimeDirectory), и добавлять ReadWritePaths точечно.
Failed to determine user credentials
Неправильный User/Group или пользователь не создан. Проверьте:
getent passwd myapp
Финальный чек‑лист перед тем как считать проблему решённой
- Сервис стабильно в состоянии
active (running)без рестарт‑петель. - Порт действительно слушается:
ss -lntp. - Ошибок в
journalctlпри старте нет, предупреждения понятны. - Права минимальны: вы не запускаете от root без необходимости, а
CapabilityBoundingSetограничен. - sandboxing включён итеративно и документирован: вы понимаете, зачем нужна каждая директива.
Если дальше хочется довести unit до «боевого» уровня, следующий шаг — добавить понятные сигналы готовности (например, Type=notify там, где это поддерживается) и ограничители ресурсов в cgroup (CPU/RAM/FD), чтобы один сбойный процесс не уронил всю машину. Но фундамент почти всегда один: корректный запуск, наблюдаемость через journalctl и минимальные, проверяемые права.
Когда сервис уже стабилен, имеет смысл переносить такие настройки на предсказуемую инфраструктуру: например, развернуть приложение на VDS, где вы полностью контролируете версии systemd, права, firewall и сетевые политики.


