Почему Environment=PATH=$PATH не расширяет PATH, хотя в bash та же строка работает
Скрипт годами крутился из cron и работал. Переносите его в systemd-юнит, копируете строчку окружения из своего .bashrc — и сервис падает: то внутри скрипта «command not found», то приложение молча не находит нужную утилиту. Руками из терминала всё запускается. Ниже — почему так происходит, что именно systemd делает с символом доллара, как правильно переносить окружение из shell в unit-файл и какой чек-лист я прогоняю на каждом таком переезде.
«$PATH» в Environment= — это просто пять символов
Начну с главного, потому что дальше всё вытекает из одного факта: systemd — не оболочка. Он не запускает /bin/sh, чтобы разобрать ваш unit-файл. Он парсит его сам, своим парсером, по своим правилам, и эти правила намеренно уже, чем у bash. В документации это сказано прямо, в описании директивы Environment=: «Variable expansion is not performed inside the strings and the "$" character has no special meaning» — подстановка переменных внутри строк не выполняется, символ доллара специального значения не имеет.
То есть строка Environment=PATH=$PATH:/opt/app/bin не «дописывает» ваш PATH. Она присваивает переменной PATH литеральную строку — те же пять символов доллар-P-A-T-H плюс хвост, буква в букву. Процесс стартует с PATH, равным строке «$PATH:/opt/app/bin». Дальше он пытается найти wkhtmltopdf, psql или ffmpeg, не находит ничего, потому что ни одного существующего каталога в этом «пути» нет, и падает. При этом сам ExecStart может отработать нормально — если вы указали в нём абсолютный путь, — и упадёт не сервис, а первый же дочерний вызов внутри вашего скрипта. Отсюда и любимая жалоба: «в консоли работает, под systemd нет».
Что в Environment= всё-таки раскрывается — так это %-спецификаторы systemd: %i (инстанс шаблонного юнита), %n (имя юнита), %t (runtime-каталог), %h и %u. С последними двумя аккуратнее: по systemd.unit(5) %h — это домашний каталог пользователя, под которым работает сам менеджер служб, а %u — его имя. Для системного менеджера это всегда /root и root, и на них не влияет User= в секции [Service]. Поэтому Environment=APP_HOME=%h/app в системном юните с User=atelier даст /root/app, а не /home/atelier/app. Долларов парсер не раскрывает вовсе. Это два разных механизма, и их постоянно путают: человек видит, что %h работает, и делает вывод, что «подстановка есть», а значит и $HOME должен подставиться. Не должен.
Проверяется всё за десять секунд. Показать, что реально получил юнит, и что реально получил процесс:
# что систем-менеджер считает окружением юнита
systemctl show -p Environment edi-worker.service
# что реально видит живой процесс
pid=$(systemctl show -p MainPID --value edi-worker.service)
tr '\0' '\n' < /proc/$pid/environ | sortЕсли в выводе первой команды вы видите PATH=$PATH:/opt/app/bin — вопрос закрыт, дальше можно не искать. Я эту пару команд прогоняю до того, как начинаю читать логи приложения: она отсекает половину «загадочных» падений после переезда в systemd.
- раскрытия $VAR и ${VAR} внутри Environment= — нет
- раскрытия тильды ~ в домашний каталог — нет
- подстановки команд $(...) и обратных кавычек — нет
- globbing по * и ? в значениях — нет
- %-спецификаторов (%i, %n, %t, %h, %u) — да, но %h и %u в системном юните дают /root и root независимо от User=
Откуда у сервиса берётся PATH и почему он короче вашего
Второй слой проблемы: даже если бы подстановка работала, расширять было бы особо нечего. Системный менеджер systemd использует фиксированное значение PATH — /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin. Всё. Никаких /opt, никаких ~/.local/bin, никаких /snap/bin, никаких путей, которые вам дописали /etc/profile.d/*.sh, ~/.bashrc или менеджер версий вроде nvm и pyenv. Сервис не читает эти файлы вообще — они относятся к сессии оболочки, а сервис оболочку не запускает. Тот же man отдельно оговаривает переменные пользователя: $USER выставляется всегда, а $HOME, $LOGNAME и $SHELL — только для юнитов, где задан User= (и не выключен SetLoginEnvironment=). Юнит без User= работает от root и может вообще не иметь $HOME в окружении, так что скрипт с cd ~ или ~/.config внутри ведёт себя иначе, чем из вашей консоли.
У пользовательского менеджера (systemd --user) картина другая: дистрибутив может настроить свой PATH, и переменные окружения менеджера там наследуются запущенными процессами. Поэтому «у меня в systemctl --user всё завелось, а в системном юните нет» — не мистика, а ровно это различие. Не переносите выводы с одного контура на другой.
Есть директива PassEnvironment=, и её регулярно пробуют как «волшебную кнопку». Она передаёт сервису переменные, установленные для менеджера служб, то есть для PID 1. У PID 1 вашего интерактивного окружения нет и быть не может, поэтому PassEnvironment=PATH обычно даёт ровно тот же фиксированный системный PATH. Переменные, не установленные у менеджера, молча игнорируются — без ошибки, без предупреждения. Для пользовательского менеджера директива вообще бессмысленна: там и так передаётся всё.
Отдельно стоит ExecSearchPath= (появилась в systemd 250). Она задаёт список абсолютных каталогов, в которых ищется исполняемый файл из Exec*=. Тонкость, которую часто понимают неверно: если PATH не задан через Environment=, EnvironmentFile= или PassEnvironment=, systemd подставляет каталоги из ExecSearchPath= ещё и в $PATH запущенного процесса — это видно и в NEWS к v250, и в исходниках (strv_env_assign(..., "PATH", joined) перед слиянием с окружением юнита). А вот если PATH вы задали сами, ваше значение побеждает, и ExecSearchPath= влияет только на поиск бинарника юнита. Без ExecSearchPath= короткое имя в ExecStart ищется по фиксированному пути, заданному при сборке systemd (/usr/local/bin, /usr/bin, /bin и их sbin-аналоги), а не по PATH из Environment=. Отсюда практическое следствие: мусорный PATH в Environment= не даёт 203/EXEC у самого ExecStart, он ломает дочерние вызовы внутри скрипта.
Посмотреть, с каким окружением реально стартуют службы на конкретной машине, можно одной командой — systemd-run печатает эффективный блок окружения системного и пользовательского менеджера. Там же видно, не добавил ли кто-то глобальных переменных через systemctl set-environment или DefaultEnvironment= в system.conf: по systemd.exec(5) они применяются ко всем процессам менеджера, но перекрываются Environment= и EnvironmentFile= конкретного юнита.
sudo systemd-run -P env # окружение системной службы
systemd-run --user -P env # окружение пользовательской службы
systemctl show-environment # переменные самого менеджера (set-environment, DefaultEnvironment)Мой рабочий вариант — задать PATH явно и целиком, перечислив нужные каталоги руками. Не элегантно, зато детерминированно и читается через год без археологии:
[Service]
Environment=PATH=/opt/app/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin
ExecSearchPath=/opt/app/bin
ExecStart=/opt/app/bin/edi-worker --profile prodИ почти всегда я кладу это не в сам unit-файл из пакета, а в drop-in — так апдейт пакета не затрёт настройку, и сразу видно, что именно мы добавили от себя:
sudo systemctl edit edi-worker.service
# создаётся /etc/systemd/system/edi-worker.service.d/override.conf
sudo systemctl daemon-reload && sudo systemctl restart edi-worker.service
systemctl cat edi-worker.service # базовый юнит + все drop-in- Environment=PATH=... — самый прямой и предсказуемый способ, применяю по умолчанию
- EnvironmentFile=/etc/default/<сервис> — когда значение зависит от машины или его формирует деплой
- ExecSearchPath= — поиск исполняемого файла юнита (с systemd 250); если PATH не задан явно, эти каталоги попадут и в $PATH процесса
- DefaultEnvironment= в /etc/systemd/system.conf — глобально на все юниты, трогаю крайне редко
- systemctl set-environment VAR=... — глобально для всех служб менеджера до перезагрузки; для отладки, не для прода
- PassEnvironment= — почти никогда: передаёт окружение PID 1, а не ваше
EnvironmentFile: похож на .env, но правила у него свои
EnvironmentFile= читает текстовый файл с присваиваниями, разделёнными переводами строк. Пустые строки, строки без символа «=» и строки, начинающиеся с «;» или «#», игнорируются — их можно использовать под комментарии. Файл обязан быть в UTF-8. Ключевая деталь, которую регулярно проходят мимо: настройки из этих файлов перекрывают то, что задано через Environment=. Не наоборот. Если у вас PATH написан и там и там — победит файл. А если файлов несколько, они читаются в порядке перечисления, и позднее присваивание переписывает более раннее.
Файл не «сорсится» шеллом. Из этого следуют три самые частые ошибки, которые я вижу у клиентов при переезде с cron-обвязки. Первая: строки вида export FOO=bar. systemd видит имя переменной «export FOO» — с пробелом, — считает его невалидным и строку выбрасывает, написав предупреждение в журнал. Приложение стартует без переменной. Вторая: комментарий в конце строки. Игнорируются только строки, которые начинаются с # или ;. Хвостовой комментарий станет частью значения, и вы получите таймаут «30 # секунд» вместо «30». Третья — та же самая, с которой мы начали: PATH=$PATH:/opt/app/bin внутри EnvironmentFile тоже не раскроется, потому что подстановки там нет.
Правила кавычек в файле специфические и на shell похожи лишь частично: неэкранированное значение после «=» разбирается по тем же правилам обратного слэша, что и неквотированный текст POSIX-шелла, но, в отличие от шелла, внутренние пробелы сохраняются, а кавычки после первого непробельного символа остаются как есть. Строка, заканчивающаяся обратным слэшем, продолжается на следующей. Значения в одинарных кавычках могут занимать несколько строк и берутся дословно, escape-последовательности в них не распознаются. Ведущие и хвостовые пробелы отбрасываются.
Ещё две полезные мелочи. Аргумент можно префиксовать дефисом — тогда отсутствие файла не считается ошибкой и не пишет ни warning, ни error: EnvironmentFile=-/etc/default/edi-worker. И файлы читаются незадолго до запуска процесса, уже после того, как завершились процессы предыдущего состояния юнита; это официально позволяет сгенерировать файл в одном состоянии юнита и прочитать его в следующем — удобно для деплоя, который вычисляет часть значений на месте.
# /etc/default/edi-worker — как НЕ надо
export EDI_HOME=/opt/edi # строка будет отброшена целиком
EDI_TIMEOUT=30 # секунд # значение станет "30 # секунд"
PATH=$PATH:/opt/edi/bin # доллар останется буквой# /etc/default/edi-worker — как надо
# EDI worker, окружение сервиса
EDI_HOME=/opt/edi
EDI_TIMEOUT=30
EDI_ARGS=--profile prod --workers 4
PATH=/opt/edi/bin:/usr/local/bin:/usr/bin:/bin- игнорируются: пустые строки, строки без «=», строки, начинающиеся с # или ;
- хвостовые комментарии в конце строки НЕ отбрасываются — они попадают в значение
- export в начале строки делает имя переменной невалидным
- значения из EnvironmentFile= перекрывают Environment=
- несколько файлов читаются по порядку, поздний перекрывает ранний
- префикс «-» перед путём: отсутствие файла не ошибка
$VAR и ${VAR} в ExecStart: где рвутся аргументы
В командных строках (ExecStart=, ExecStartPre=, ExecReload=, ExecStop= и прочие Exec*=) подстановка переменных как раз выполняется — но по правилам, которые отличаются и от Environment=, и от шелла. Правило ровно одно, и его стоит выучить наизусть. Конструкция ${FOO} — как часть слова или как отдельное слово — заменяется точным значением переменной, включая все пробелы внутри, и всегда даёт ровно один аргумент. Конструкция $FOO — только как отдельное слово — заменяется значением, разбитым по пробелам, и даёт ноль, один или несколько аргументов; при этом разбиении кавычки учитываются, а после разбиения снимаются.
Пример из документации, который лучше любых объяснений:
Environment="ONE=one" 'TWO=two two'
ExecStart=echo $ONE $TWO ${TWO}Здесь /bin/echo получит четыре аргумента: «one», «two», «two» и «two two». Первые три — результат разбиения по пробелам, последний — единое значение в фигурных скобках. Именно отсюда растут баги в духе «путь с пробелом превратился в два аргумента» и «программа ругается на неизвестный параметр, которого я не писал». Практическое следствие: если переменная содержит путь, имя файла или что угодно, где допустим пробел, — всегда ${VAR}. Если переменная содержит список флагов, который вы сознательно хотите разбить на отдельные аргументы, — тогда $VAR отдельным словом.
Дальше — четыре вещи, которые надо знать про Exec-строки, чтобы не терять вечера. Переменная, значение которой неизвестно на момент подстановки, считается пустой строкой — никакой ошибки не будет, юнит просто стартует с недостающим аргументом, и приложение упадёт где-то глубже с невнятной диагностикой. Первый аргумент, то есть сама запускаемая программа, переменной быть не может. Литеральный знак доллара пишется как $$. И префикс «:» перед путём отключает подстановку переменных для этой команды целиком — иногда это ровно то, что нужно, когда аргумент честно должен содержать доллар.
И последнее: шелла в ExecStart нет. Перенаправления <, <<, >, >>, конвейеры |, фоновый запуск через & и прочие элементы синтаксиса оболочки не поддерживаются. Если нужен конвейер — вызывайте интерпретатор явно: ExecStart=sh -c 'dmesg | tac'. Никакой магии тут нет, но и злоупотреблять этим не стоит: без exec внутри обёртки главным процессом юнита может остаться шелл, и тогда SIGTERM при остановке получает он, а не ваше приложение. Пишите sh -c 'exec /opt/app/bin/worker ...'.
- ${VAR} — всегда ровно один аргумент, пробелы внутри сохраняются
- $VAR отдельным словом — разбивается по пробелам, от нуля до N аргументов
- неизвестная переменная = пустая строка, без ошибки и без предупреждения
- имя запускаемой программы переменной быть не может
- $$ — литеральный доллар; префикс «:» отключает подстановку для команды
- пайпов, редиректов и & нет — нужен явный sh -c
Разбор из практики: обработчик ЭДО в «АтельеПошив»
Пошивочное предприятие «АтельеПошив», 46 рабочих мест: цех, закройный участок, склад тканей и фурнитуры, учёт на 1С и самописный обработчик обмена документами с поставщиками и заказчиками корпоративной спецодежды — Python, PDF-рендер через wkhtmltopdf, выгрузка в контур ЭДО. Несколько лет он жил как cron-задание под отдельным пользователем: строка в crontab, обёртка bash, в обёртке — привычные export PATH=... и source /etc/edi.env. Работало. В июне 2026-го мы перенесли контур на новый Ubuntu 24.04 LTS (systemd 255.4) и заодно решили переехать с cron на нормальный systemd-юнит с Restart=on-failure и журналированием. Переезд занял вечер, а разгребали три дня — и все три дня упирались ровно в тему этой статьи.
Первое падение было честное и быстрое. ExecStart был прописан абсолютным путём, поэтому сам обработчик стартовал, но на первом же документе падал с «wkhtmltopdf: not found», выходил с кодом 1, и юнит уходил в цикл перезапусков status=1/FAILURE. В юните стояла строка, скопированная из старой bash-обёртки: Environment=PATH=$PATH:/opt/wkhtmltox/bin. Заглянули в systemctl show -p Environment — увидели PATH=$PATH:/opt/wkhtmltox/bin дословно. Заменили на полный явный список каталогов. Сервис поднялся.
Второе было злее и вылезло через сутки. В EnvironmentFile лежала строка EDI_PROFILE=edo main — имя боевого профиля обмена с пробелом, — а в юните ExecStart=/opt/edi/bin/worker --profile $EDI_PROFILE. $VAR отдельным словом разбивается по пробелам: приложение получило --profile edo и лишний позиционный аргумент main, который молча проигнорировало. Профиль «edo» в конфиге оказался тестовой песочницей оператора, и за ночь обработчик отправил 64 документа не туда. Ошибку заметил бухгалтер, а не мониторинг. Чинили двумя правками: ${EDI_PROFILE} там, где нужен цельный аргумент, и разнесение флагов по отдельным переменным вместо одной строки на всё.
Третье было тихим и нашлось только при разборе журнала. В том же /etc/default/edi-worker жила строка export EDI_RETRY=5 — привычка из шелла. systemd отбросил её как невалидное имя переменной, приложение взяло дефолт 0 и переставало ретраить при первом же таймауте контрагента. Плюс рядом обнаружилась строка EDI_TIMEOUT=30 # сек, из-за которой в переменную уезжал хвост с решёткой. Итоговый набор файлов после чистки выглядел так:
# /etc/systemd/system/edi-worker.service.d/10-env.conf
[Service]
Environment=PATH=/opt/edi/bin:/opt/wkhtmltox/bin:/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/default/edi-worker
ExecSearchPath=/opt/edi/bin
ExecStartPre=/opt/edi/bin/check-env.sh
ExecStart=/opt/edi/bin/worker --profile ${EDI_PROFILE} --workers ${EDI_WORKERS}
Restart=on-failure
RestartSec=15# /etc/default/edi-worker
EDI_PROFILE=edo main
EDI_WORKERS=4
EDI_TIMEOUT=30
EDI_RETRY=5Итог: очередь из 2 700 накопившихся документов ушла за 20 минут, 64 ошибочные отправки аннулировали руками через оператора ЭДО ещё полдня. С июля по сентябрь 2026-го — ни одного падения по окружению. Вывод, который я после этого случая проговариваю всем клиентам: перенос из cron в systemd — это не «скопировать строчки», это переписать окружение заново, с проверкой каждого значения на живом процессе. Час на аккуратный перенос дешевле трёх дней разбора.
- 203/EXEC — файла по пути из ExecStart нет, нет бита +x, битый shebang или короткое имя программы вне стандартных каталогов; мусорный PATH в Environment= даёт не 203, а падения дочерних вызовов
- проверяйте не unit-файл, а окружение живого процесса: /proc/<pid>/environ
- строку EDI_ARGS=«вся команда одной переменной» лучше разнести на отдельные переменные
- после любой правки: systemctl daemon-reload, иначе вы отлаживаете старую версию юнита
Мой чек-лист переноса окружения из shell в unit
Последовательность, которую я прогоняю на каждом переезде. Она короткая и закрывает практически все грабли, разобранные выше.
Сначала — снять эталон. Запустите приложение так, как оно работает сейчас, и сохраните его настоящее окружение: tr '\0' '\n' < /proc/<pid>/environ | sort > /tmp/env.old. Это ваш опорный список. Потом отфильтруйте из него всё, что относится к интерактивной сессии — LS_COLORS, TERM, SSH_*, HISTFILE, XDG_*, — и оставьте только то, что реально нужно приложению. Обычно это пять-восемь переменных, а не сорок.
Дальше — перенести оставшееся в юнит явными значениями, без единого доллара в Environment=. Всё, что зависит от машины, — в EnvironmentFile=. Всё, что похоже на пароль, ключ или токен, — не в переменные окружения вообще: документация об этом говорит прямо, окружение юнита видно непривилегированным клиентам через D-Bus и наследуется вниз по дереву процессов, включая setuid/setgid-бинарники. Для секретов есть LoadCredential=, LoadCredentialEncrypted= и SetCredentialEncrypted=.
И в конце — сверка. Не «сервис запустился», а сверка списков:
sudo systemctl daemon-reload && sudo systemctl restart edi-worker
systemctl show -p Environment -p ExecStart edi-worker
pid=$(systemctl show -p MainPID --value edi-worker)
tr '\0' '\n' < /proc/$pid/environ | sort > /tmp/env.new
diff /tmp/env.old /tmp/env.new
journalctl -u edi-worker -b --no-pager | tail -50Отдельно про быструю проверку гипотез: systemd-run умеет запускать транзиентный юнит с теми же свойствами, что и настоящий, и это в разы быстрее, чем править файл и делать daemon-reload по кругу. Например, systemd-run -p 'Environment=PATH=/opt/app/bin:/usr/bin' --wait --pty /opt/app/bin/worker --check покажет поведение ровно в том окружении, которое вы собираетесь прописать. Я так проверяю сомнительные значения до того, как они попадут в файл.
- снять реальное окружение работающего процесса и сохранить как эталон
- выкинуть из него всё интерактивное: TERM, LS_COLORS, SSH_*, XDG_*
- PATH прописать полностью и явно, без $PATH
- секреты — не в Environment=, а в LoadCredential=
- настройки под конкретную машину — в EnvironmentFile= с префиксом «-»
- после правок обязательно systemctl daemon-reload
- сверить /proc/<pid>/environ до и после через diff
- проверять гипотезы через systemd-run -p, а не правкой файла
Что здесь спорно и на что можно спокойно забить
Первое, что скажу честно: поведение systemd тут неудобное, но правильное. Периодически всплывают предложения «пусть Environment= раскрывает переменные, как шелл». Единого мнения в сообществе нет до сих пор, но аргумент разработчиков systemd я разделяю: как только парсер юнитов начнёт делать подстановки, за ними придут кавычки, экранирование, подстановка команд, и unit-файл превратится в скрипт с непредсказуемой семантикой и новым классом инъекций. Текущее поведение скучное, зато детерминированное: что написано — то и в окружении. Ждать, что это «когда-нибудь починят», не стоит — это не баг.
Второе, спорное. Есть популярный обходной путь: ExecStart=/bin/bash -lc 'exec /opt/app/bin/worker'. Логин-шелл прочитает профили, PATH соберётся сам, всё «заработает». Я так почти не делаю. Причины: вы теряете контроль над тем, откуда взялось значение (пойди пойми, какой из десятка файлов в /etc/profile.d его дописал), окружение сервиса начинает зависеть от правок, которые кто-то сделал «для удобства в консоли», а лишний слой bash усложняет доставку сигналов и корректную остановку. Исключение делаю для legacy-приложений вроде старых Java-обвязок или 1С-утилит, где окружение действительно собирается сложной цепочкой скриптов и переписывать её дороже, чем принять компромисс. Тогда — bash -lc, но с exec, чтобы главный PID был правильный, и обязательно с User=: без него у службы нет $HOME, и login-шелл root прочитает не те профили, что у пользователя приложения.
Ещё один «быстрый» обход — systemctl set-environment PATH=... на уровне менеджера. Он действительно доходит до служб: systemd.exec(5) перечисляет set-environment среди источников окружения для каждого запускаемого процесса. Но это глобальная правка для всех юнитов разом, она не видна в unit-файле и живёт до перезагрузки. Для проверки гипотезы на стенде — годится, для продакшена — нет: через полгода никто не вспомнит, откуда у всех служб взялся странный PATH.
Третье — то, на что можно забить. Не нужно превентивно вычищать PassEnvironment= и DefaultEnvironment= из чужих юнитов: они почти никогда ничего не ломают, просто чаще всего ничего и не делают. Не нужно бороться за минимальный PATH в служебных юнитах, которые вы не писали. И не нужно переносить в юнит LANG, LC_*, TERM «на всякий случай»: если приложению реально нужна локаль, вы это увидите по кракозябрам в журнале за пять минут, а до тех пор лишние переменные только шумят.
И четвёртое, важное для тех, кто держит парк из десятков серверов. Правьте не файлы юнитов из пакетов, а drop-in через systemctl edit. Обновление пакета перезапишет /lib/systemd/system/foo.service и молча снесёт вашу настройку — обычно это выясняется через недели, когда сервис после планового апдейта поднялся с чужим окружением. Drop-in в /etc/systemd/system/foo.service.d/ переживает обновления и, что не менее ценно, честно отвечает на вопрос «а что здесь наше?» — systemctl cat покажет и базовый юнит, и все наложенные поверх куски.
- Environment= без подстановки — не баг и не будет «исправлено»
- bash -lc как обёртка — рабочий, но не бесплатный компромисс; только с exec
- systemctl set-environment — глобально и до ребута, только для отладки
- drop-in через systemctl edit вместо правки пакетных юнитов
- секреты — через LoadCredential=, а не через окружение
Частые вопросы
Можно ли всё-таки дописать каталог к существующему PATH прямо в unit-файле?
Штатными средствами — нет: в Environment= подстановки нет, а в EnvironmentFile= её тоже нет. Варианта три. Первый и основной: прописать PATH целиком, явно перечислив каталоги. Второй: сгенерировать EnvironmentFile скриптом при деплое — файлы читаются незадолго до запуска процесса, поэтому их можно сформировать в одном состоянии юнита и прочитать в следующем. Третий, компромиссный: обёртка ExecStart=/bin/bash -lc 'exec /path/to/app'. Есть и четвёртый — systemctl set-environment, но он глобальный для всех служб и живёт до перезагрузки, поэтому годится только для отладки. Я почти всегда выбираю первый.
Почему сервис падает с status=203/EXEC?
203/EXEC означает, что systemd не смог выполнить файл из ExecStart. Типовые причины: неверный или несуществующий путь, нет бита +x, битый shebang (интерпретатор не найден), либо в ExecStart указано короткое имя программы, которой нет в стандартных каталогах (/usr/local/bin, /usr/bin, /bin и sbin-аналоги) или в ExecSearchPath=. PATH из Environment= на поиск самого ExecStart не влияет, так что литеральный «$PATH» даёт не 203, а ошибки внутри приложения. Проверяйте по порядку: ls -l на путь из ExecStart, head -1 скрипта, потом systemctl show -p Environment и /proc/<pid>/environ.
Что имеет приоритет — Environment= или EnvironmentFile=?
EnvironmentFile=. В документации сказано прямо: настройки из этих файлов перекрывают заданные через Environment=. Если файлов несколько, они читаются в порядке перечисления в юните, и более позднее присваивание перекрывает более раннее. Это частая причина «я же прописал PATH в юните, а он другой»: значение молча переписывается файлом.
Чем ${VAR} отличается от $VAR в ExecStart?
${VAR} подставляется как точное значение переменной со всеми пробелами внутри и всегда даёт ровно один аргумент. $VAR, записанный отдельным словом, подставляется со splitting по пробелам и даёт ноль, один или несколько аргументов. Для путей, имён файлов и любых значений, где возможен пробел, используйте только фигурные скобки.
Работает ли в Environment= подстановка %-спецификаторов?
Да, спецификаторы раскрываются — %i, %n, %t, %h, %u и остальные из systemd.unit(5). Но %h и %u описывают пользователя, под которым работает сам менеджер служб: в системном юните это /root и root, даже если задан User=. Именно это чаще всего и вводит в заблуждение: человек видит, что %h работает, и ожидает, что $HOME тоже подставится. Это разные механизмы: спецификаторы обрабатывает парсер юнитов, а $-переменные — только логика командных строк Exec*=.
Где хранить пароли и токены для сервиса, если не в переменных окружения?
В systemd-креденшлах: LoadCredential=, LoadCredentialEncrypted=, SetCredentialEncrypted=. Документация прямо предупреждает, что окружение юнита не подходит для секретов — оно доступно непривилегированным клиентам через D-Bus и наследуется вниз по дереву процессов, включая переходы через setuid/setgid-бинарники. Креденшлы отдаются процессу файлами в приватном каталоге и в чужие руки не утекают.
Почему в системной службе нет $HOME, хотя в консоли он есть?
По systemd.exec(5) $USER выставляется всегда, а $HOME, $LOGNAME и $SHELL — только для юнитов с User= (при не выключенном SetLoginEnvironment=). Shell-профили служба не читает. Если приложению нужен домашний каталог, задайте User= с нормальным домашним каталогом в passwd или пропишите нужный путь явно через Environment=; WorkingDirectory=~ тоже возьмёт домашний каталог пользователя из User=.
Источники
- systemd.exec(5) — Environment=, EnvironmentFile=, PassEnvironment=, ExecSearchPath= — «Variable expansion is not performed inside the strings and the "$" character has no special meaning. Specifier expansion is performed»; правила разбора EnvironmentFile; порядок источников окружения (DefaultEnvironment, set-environment, Environment=, EnvironmentFile=); $PATH = /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin в системном менеджере; $HOME/$SHELL только при User=. https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html
- systemd.service(5) — Command lines — ${FOO} — ровно один аргумент, $FOO — разбиение по пробелам с учётом кавычек; неизвестные переменные — пустая строка; $$; префикс «:»; поиск короткого имени по фиксированному пути, заданному при сборке; нет пайпов и редиректов. https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
- systemd.unit(5) — Specifiers — %h и %u: домашний каталог и имя пользователя, под которым работает менеджер служб; для системного менеджера — /root и root, User= на них не влияет. Drop-in каталоги .d/. https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html
- systemd.syntax(7) — Quoting — Правила снятия кавычек, по которым разбираются строки Environment= и командные строки Exec*=. https://man7.org/linux/man-pages/man7/systemd.syntax.7.html
- systemctl(1) и systemd-system.conf(5) — set-environment, DefaultEnvironment= — set-environment задаёт переменные менеджера (с версии 233); DefaultEnvironment= — переменные для всех запускаемых процессов. https://www.freedesktop.org/software/systemd/man/latest/systemctl.html
- systemd NEWS — v250: ExecSearchPath= влияет на поиск бинарников Exec*= и добавляет каталоги в $PATH процесса. v258 — 17.09.2025, v259 — 17.12.2025, v261 — 08.09.2026; семантика Environment=/Exec*= не менялась. https://github.com/systemd/systemd/blob/main/NEWS
