环境文件把部署取值与描述进程监管的单元定义分开。systemd 在执行服务命令前读取指定文件,再把取值放入那个进程的环境。修改文件不会直接重写已经运行的进程环境。不过,重载辅助命令本身也是一个新进程,因此可能读到比现有主进程更新的文件值。理解这个执行时点,比笼统记住“改配置后做一次 reload”更可靠。
坏掉的样子 daemon-reload 要求管理器重新读取单元定义、更新依赖信息;reload 则请求服务执行已配置的重载操作,例如 ExecReload。两者都不是把现有进程环境整体替换掉的通用指令。服务可以自行实现应用配置刷新,但这种能力必须来自应用或重载辅助程序,不能仅凭管理器接受了一个重载请求就认定业务设置已经切换。
EnvironmentFile 路径前加短横线,表示这个文件可以不存在。缺失时 systemd 会忽略它,并不会发出操作者可能期待的那条警告。因此,路径笔误可能让一个覆盖配置悄悄消失。只有缺少该文件确实具有合法含义时,才适合使用可选形式;必需的部署输入不应借这个前缀被静默跳过,否则很容易把供应错误变成难以解释的默认行为。
文件由 systemd 解析,不是交给 shell 加载。它有自己的引号和转义规则,但不会把命令替换和变量展开当作 shell 执行。包含 $HOME 的值仍然是这段文本。后列出的文件覆盖前面的同名值,文件赋值又覆盖单元中的 Environment 赋值。因此,需要检查真实优先级,不能认为自己首先看见的那一行就是最终值。
快速验证 在 PID 1 为 systemd 的隔离 Linux 环境中,以管理员身份运行下面的 Python 脚本。它只使用无害的样本设置与临时路径,实测环境为 Debian 12、systemd 252.39。确认运行时单元名称尚未使用。工作程序仅记录四个特定测试值,不会把整个环境全部打印出来:
from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,time unit='xbcatm-environment-lab.service';unit_path=Path('/run/systemd/system')/unit assert not unit_path.exists() def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],capture_output=True,text=True,timeout=10) assert (result.returncode==0)==success,(args,result.stderr) return result.stdout.strip() def value(key):return ctl('show',unit,'-p',key,'--value') def wait_for(check): deadline=time.monotonic()+5 while not check(): assert time.monotonic()<deadline,'state transition timed out' time.sleep(0.02) with TemporaryDirectory(prefix='xbcatm-environment-',dir='/run') as directory: root=Path(directory);first=root/'required.env';second=root/'optional.env';worker=root/'worker.py' command=f'$(touch {root}/unexpected)' initial=f'MODE=first\nLITERAL=$HOME\nQUOTED="two words"\nCOMMAND={command}\n' first.write_text(initial);first.chmod(0o600);second.write_text('MODE=second\n') worker.write_text("""from pathlib import Path import json,os,sys,time root=Path(__file__).parent;name=sys.argv[1] data={key:os.environ.get(key) for key in ('MODE','LITERAL','QUOTED','COMMAND')} data['pid']=os.getpid() tmp=root/(name+'.tmp');tmp.write_text(json.dumps(data));tmp.replace(root/(name+'.json')) if name=='main': time.sleep(60) """) unit_path.write_text(f'''[Service] Type=exec Environment=MODE=unit-default EnvironmentFile={first} EnvironmentFile=-{second} ExecStart=/usr/bin/python3 {worker} main ExecReload=/usr/bin/python3 {worker} reload ''') def read(name):return json.loads((root/(name+'.json')).read_text()) try: subprocess.run(['systemd-analyze','verify',str(unit_path)],check=True) ctl('daemon-reload');ctl('start',unit) wait_for(lambda:(root/'main.json').exists()) current=read('main');assert current['MODE']=='second' assert current['LITERAL']=='$HOME' and current['QUOTED']=='two words' assert current['COMMAND']==command and not (root/'unexpected').exists() assert 'MODE=unit-default' in value('Environment') print('ordered environment files override unit value; quoting works; shell text stays literal') second.write_text('MODE=third\n') ctl('daemon-reload');assert value('MainPID')==str(current['pid']) live=Path(f'/proc/{current["pid"]}/environ').read_bytes().split(b'\0') assert b'MODE=second' in live ctl('reload',unit) assert read('reload')['MODE']=='third' and read('main')==current assert value('MainPID')==str(current['pid']) live=Path(f'/proc/{current["pid"]}/environ').read_bytes().split(b'\0') assert b'MODE=second' in live print('reload helper receives new file value; existing main process retains old environment') second.write_text('MODE=fourth\n');ctl('restart',unit) wait_for(lambda:read('main')['pid']!=current['pid']) assert read('main')['MODE']=='fourth' print('restart reads changed environment file without another daemon-reload') first.unlink() subprocess.run(['systemd-analyze','verify',str(unit_path)],check=True) ctl('restart',unit,success=False) assert value('ActiveState')=='failed' and value('MainPID')=='0' print('missing required environment file prevents start') first.write_text(initial);first.chmod(0o600);second.unlink() ctl('reset-failed',unit);ctl('start',unit) wait_for(lambda:read('main')['MODE']=='first') print('missing optional file is ignored; required-file value remains') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) unit_path.unlink();ctl('daemon-reload') 主进程最初得到第二份文件中的模式值:可选的第二份文件覆盖必需的第一份文件,第一份又覆盖单元默认值。带引号的内容正确解析为包含空格的两个词,看起来像变量和命令的文本则保持字面值;如果真经过 shell 替换本应产生的那个文件,也确实没有出现。实验同时验证了优先级与解析边界,而不是只展示几行配置样子。
...