systemd 找不到 ${APP}:ExecStart 的程序名不能用环境变量
ExecStart=${APP} 找不到程序时,区分程序名和参数的变量规则,用 drop-in 清除旧命令并写入固定路径,再检查重载、重启结果与回退范围。
服务启动时出现下面的日志,明明环境文件里已经写了程序的完整路径,文件也存在:
Failed at step EXEC spawning ${APP}: No such file or directory
Main process exited, code=exited, status=203/EXEC
如果日志中的路径仍是字面上的 ${APP},服务配置又写着 ExecStart=${APP},先检查命令解析规则。修改真实程序的权限,不能让 systemd 把这个变量换成程序路径。
这篇文章处理的是程序名位置的环境变量。若日志已经显示真实路径,例如 /opt/example-app/bin/server,仍报 203/EXEC,就需要沿那个路径检查文件、执行权限、解释器和服务的文件系统限制,不能凭退出码认定是同一个原因。
为什么参数能展开,程序名却不行
ExecStart= 的命令行由 systemd 解析,不会自动交给你登录时的 shell。命令行中有两部分:第一项是要执行的程序,不能是环境变量;后面的参数支持环境变量替换。
例如下面的写法存在问题:
[Service]
Environment=APP=/opt/example-app/bin/server
Environment=CONFIG=/etc/example-app/config.toml
ExecStart=${APP} --config ${CONFIG}
把程序位置固定下来后,配置文件路径仍可以作为参数传入:
[Service]
Environment=CONFIG=/etc/example-app/config.toml
ExecStart=/opt/example-app/bin/server --config ${CONFIG}
这里的服务名、程序路径、配置路径都是示例;--config 也必须换成你的程序实际支持的参数。片段只展示要修改的设置,已有的 User=、工作目录、服务类型和其他参数应保留。
程序可以用绝对路径,也可以使用不含斜杠的简单文件名,后者按 systemd 的查找规则定位。排错时写已确认的绝对路径比较直观。EnvironmentFile= 能向服务提供变量,但不会取消第一项的限制;改成 $APP 或给 ${APP} 加引号也没有解决这个问题。
${CONFIG} 会把变量值作为一个参数传入,包括值中的空格。单独使用 $CONFIG 则可能按空白拆成多个参数。路径应使用前一种写法;未定义的变量会按空字符串处理,所以还要确认配置确实提供了 CONFIG。
先检查正在使用的服务配置
以下命令面向系统级服务。把 example-app.service 换成实际名称。用户级服务需要在所属用户下使用 systemctl --user,配置目录也要相应调整。
sudo systemctl cat example-app.service
sudo systemctl show example-app.service \
-p FragmentPath -p DropInPaths -p ExecStart -p EnvironmentFiles
sudo journalctl -u example-app.service -b -n 80 --no-pager
cat 会显示主 unit 和 drop-in 文件,能找到是谁写入或覆盖了 ExecStart。show 查看管理器已经加载的设置,用来判断你修改的磁盘文件是否已生效。两者不一致时,先核对文件位置和是否执行过 daemon-reload,不要在错误的文件上继续加配置。磁盘文件的内容可能与管理器当前理解的配置不同。
日志用来区分失败发生在哪一层。203/EXEC 表示实际执行阶段失败,具体是 execve() 失败。它本身并不说明某个环境变量没有展开,也不等于应用已经启动后返回了业务错误。
查看这些输出时,注意其中可能含有环境变量、启动参数和配置位置。不要把包含密码或令牌的完整输出粘贴到公开讨论中。
用 drop-in 改回固定的程序路径
对软件包提供的服务,通常可以在 /etc/systemd/system/ 下做覆盖,避免直接修改软件包的 unit。
编辑前保存现有 override.conf 的副本;如果原本没有这个文件,记下本次新建的路径。备份用 .bak 等不以 .conf 结尾的名称,以免也被作为 drop-in 加载。然后打开覆盖配置:
sudo systemctl edit example-app.service
在编辑器指定的有效区域加入下面的内容。例子假定原 unit 已通过 Environment= 或 EnvironmentFile= 提供了 CONFIG:
[Service]
ExecStart=
ExecStart=/opt/example-app/bin/server --config ${CONFIG}
第一行空的 ExecStart= 用来清除之前的启动命令,下一行才是替代命令。只追加一条非空配置,可能留下多条启动命令;除 Type=oneshot 外,服务要求恰好一条 ExecStart 命令。覆盖时应复制原命令需要保留的全部参数,不能只改路径就丢掉监听地址、数据目录等设置。
保存后重新查看 systemctl cat,确认改的是目标服务。如果后面还有其他 drop-in 重设 ExecStart,需要先解决覆盖顺序,否则这份修改仍可能不是最终配置。
sudo systemctl daemon-reload
sudo systemctl show example-app.service -p ExecStart -p DropInPaths
systemctl edit 保存成功后会重载 unit 配置。这里再显式执行 daemon-reload,也适用于你随后手工修改文件的情况。它不会重启现有进程;普通的 systemctl reload 则是让应用重载自己的配置,不能代替重新加载 unit。
重启后分别检查执行阶段和应用状态
修改启动命令后,重启会中断服务。先确认程序路径、参数和配置文件都有效,并在允许短暂停机的时段操作:
sudo systemctl restart example-app.service
sudo systemctl status example-app.service --no-pager -l
sudo systemctl show example-app.service \
-p ActiveState -p SubState -p MainPID -p ExecStart -p ExecMainStatus
sudo journalctl -u example-app.service -b --since '5 minutes ago' --no-pager
新的 ExecStart 应显示真实程序路径,最新日志中不应再出现试图执行字面 ${APP} 的错误。还要检查应用自己的健康接口、实际请求或作业结果;systemctl restart 返回成功不能单独证明业务可用。常驻服务应保持在符合其类型的运行状态,而一次性任务正常完成后可能是 inactive,不能只按有没有 active 判断。
如果错误变成了真实路径的 Permission denied,或应用开始报告配置文件不存在,说明后续还有另一处问题。保留新日志,按新的错误继续定位;不需要通过放宽整个目录权限来处理原来的变量限制。
需要撤销修改时,恢复编辑前的 override.conf。只有在本次新建且里面仅有本次修改时,才删除这个文件;保留其他已有覆盖内容。随后再次执行 daemon-reload,检查 ExecStart,并在同样的停机条件下重启。不要直接用 systemctl revert 清掉该服务所有本地覆盖。
升级时把新路径写入同一份覆盖配置
把程序路径放进环境文件,是为了升级时少改一处配置,但当前规则并不支持把它直接放到 ExecStart 的第一项。路径变化时,更新对应 drop-in 中的程序路径,保留必要参数,再按上面的流程重新加载和验收。
不同 systemd 版本可能在路径校验或执行阶段给出不同提示,不能要求每台机器都报出同样的 203/EXEC。判断应落在实际 ExecStart 的第一项及所用版本的命令行规则上,不能把升级 systemd 当作已确认的修复方案。


