观察、决策、行动三个方框,旁边提示创建缺少的服务账号。

systemd-sysusers 创建缺少的服务账号

服务包经常需要先准备专用账号,才能拥有文件或避免以 root 身份运行。systemd-sysusers 用一份小配置声明账号及其组,核心操作是创建缺少的身份,并建立要求的成员关系。它不是通用账号迁移引擎,不负责创建所有目录,也不保证自动分配的数字标识在每台机器上都相同。把这些职责分清楚,才能判断声明已经执行究竟证明了什么。 坏掉的样子 u 条目创建缺少的系统用户,通常也创建同名主组;g 创建组,m 添加附加组关系。ID 写成 - 表示自动分配,r 条目可以限制分配池,但已有账号占用了某个数字,就会改变剩余可用范围。因此相同的分配池并不能建立跨机器身份一致性,尤其不能因为两边使用同一份配置,就假定共享存储里的数字所有者一定对应同一人。 更改既有账号的声明,不会自动为它重新编号,也不会自动更新家目录和描述字段。这种行为便于重复执行包初始化,但同时意味着配置差异不等于活动账号记录已经发生变化。真正迁移 UID,需要另外处理既有文件归属、正在运行的进程、共享存储和回退策略;不能只改一行声明,再把命令返回成功当作迁移完成。 家目录列设置的是账号数据库字段,不会创建实际目录。因此,一个服务可能已经拥有有效账号,却仍因为状态路径不存在或不可写而启动失败。需要目录时,应使用合适的受管理服务目录或 tmpfiles 规则,明确创建、所有权和权限。账号存在与目录可用,是两项独立条件,应沿真实服务启动路径分别验证。 快速验证 在装有 systemd-sysusers 的一次性 Linux 环境中,以管理员身份运行下面的 Python 脚本。它不修改机器正在使用的账号数据库,实测环境为 Debian 12、systemd 252.39。每组测试都通过 --root 指向单独的临时根目录;shadow 数据库只在内部比较内容,不会把值打印出来: from pathlib import Path from tempfile import TemporaryDirectory import subprocess definition='''r - 61000-61010 u field-worker - "Field worker" /var/lib/field-worker /usr/sbin/nologin g field-readers - m field-worker field-readers ''' def prepare(root,occupied=None): (root/'etc').mkdir(parents=True) passwd='root:x:0:0:root:/root:/bin/sh\n';group='root:x:0:\n' if occupied is not None: passwd+=f'occupied:x:{occupied}:{occupied}:Fixture:/nonexistent:/usr/sbin/nologin\n' group+=f'occupied:x:{occupied}:\n' (root/'etc/passwd').write_text(passwd);(root/'etc/group').write_text(group) directory=root/'usr/lib/sysusers.d';directory.mkdir(parents=True) (directory/'field.conf').write_text(definition) def apply(root): result=subprocess.run(['systemd-sysusers','--root='+str(root)],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr def entries(root,name): return {line.split(':')[0]:line.split(':') for line in (root/'etc'/name).read_text().splitlines()} with TemporaryDirectory(prefix='xbcatm-sysusers-') as directory: first=Path(directory)/'first';second=Path(directory)/'second';prepare(first);apply(first) worker=entries(first,'passwd')['field-worker'];groups=entries(first,'group') assert worker[3]==groups['field-worker'][2] assert 'field-worker' in groups['field-readers'][3].split(',') assert worker[5:]==['/var/lib/field-worker','/usr/sbin/nologin'] assert not (first/'var/lib/field-worker').exists() before={name:(first/'etc'/name).read_bytes() for name in ('passwd','group','shadow','gshadow')} apply(first) assert before=={name:(first/'etc'/name).read_bytes() for name in before} print('user, primary group, and supplementary membership created; repeat leaves databases unchanged') print('home and nologin shell recorded; home directory was not created') prepare(second,int(worker[2]));apply(second) other=entries(second,'passwd')['field-worker'] assert other[2]!=worker[2] print('same allocation range and definition choose different UIDs when existing accounts differ') changed=definition.replace('u field-worker - "Field worker" /var/lib/field-worker', 'u field-worker 62000 "Changed description" /changed-home') (first/'usr/lib/sysusers.d/field.conf').write_text(changed);apply(first) assert entries(first,'passwd')['field-worker']==worker print('changing a declaration does not renumber or rewrite an existing account') assert not (first/'changed-home').exists() print('only temporary alternate-root account databases were changed') 第一份根目录起初只有样本 root 账号。应用声明后,会生成工作用户、它的主组、读者组,以及工作用户的附加组成员关系。脚本检查数字主组关系和实际成员列表,而不是只检查配置能够解析。这样可以明确证明创建结果满足所声明的访问结构,避免把一个存在的组误认为用户已经加入其中。 ...

六月 8, 2027 · 2 分钟 · XB Field Notes
脚本可见、能直接把整份位置参数暴露出来的内建变量。

$@ 等特殊变量给出函数的完整入参清单

包装脚本应当把收到的参数按原有边界继续传递。在 Bash 的命令参数位置,"$@" 会让每个位置参数分别成为一个带引号的词,因此能保留空值、空格、换行和字面通配符。只用两个简单文件名测试成功,并不能证明包装脚本正确;一旦它把参数列表拼成字符串再拆开,真实调用者传入的边界就可能丢失。转发是否可靠,应当用能暴露这种差异的输入来验证。 参数列表与拼接文本不同 位置参数属于当前脚本或正在执行的函数。$# 返回参数数量,内容为空字符串的参数也计算在内。"$1" 读取第一个参数,"${10}" 读取第十个;后者需要花括号,以区别于第一个参数后面接字符零的写法。$0 表示启动的 shell 或脚本名称,不会作为额外元素出现在 "$@" 中,不能把它误算进业务参数列表。 在普通命令边界上,"$@" 为每个参数保留一个独立词;完全没有位置参数时,它不产生任何参数。相比之下,"$*" 始终形成一个字符串,用 IFS 的第一个字符连接各个值。IFS 未设置时使用空格,设为空字符串时则不插入分隔符。它并不会删除参数内部原有的空格,但各参数原来的边界通常已经无法恢复,因此不适合转发任意列表。 省略引号的 $@ 不是等价简写,展开后的内容仍可能被分词和文件名展开处理。调用者传入的字面星号,到了包装脚本所在目录可能突然匹配出一组文件。往拼接文本里补引号字符也不能恢复结构。需要把列表保存到之后使用时,在 Bash 中写成 args=("$@"),并通过 "${args[@]}" 转发,即使随后调整位置参数,保存的列表仍保留各个元素。 坏掉的样子 函数执行期间拥有自己的位置参数列表。调用时传入的参数暂时替换当前可见列表,返回时恢复调用者的列表。函数内部使用 set 或 shift 修改的是这次函数调用的参数,但这不代表其他所有 shell 状态都自动局部化。例如,没有局部声明就修改 IFS,可能影响调用者之后的命令。参数作用域和普通变量作用域应分别理解,不能互相推导。 Shift 会删除前面的若干参数,并对剩余参数重新编号。解析选项时,必须先确认剩余参数足够,再消费选项及其值;要求删除的数量超过现有参数时会失败。仅检查当前数量大于零,还不足以处理需要额外值的选项。如果之后的错误提示或转发还需要被消费的原始值,应在开始解析前保存数组,避免试图从拼接日志中重新恢复。 引号也不能阻止接收程序把完整参数解释为选项。例如,文件名恰好为 --help 时,"$@" 会完整保留它,但目标工具仍可能显示帮助而不读取文件。传入操作数时,应使用工具文档规定的选项终止符,或适当添加路径前缀。包装脚本必须明确自己的选项在哪里结束,以及哪些剩余参数仍是下游命令的选项,不能不加区分地自动插入分隔符。 ...

四月 16, 2027 · 2 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现命令状态解释。

退出码是用一个数字汇报成败

退出状态是 shell 用来决定下一步执行什么的小整数。对 shell 控制流来说,零表示成功,非零进入失败分支,但这并不意味着每个数字都有统一的业务含义。搜索没有匹配、输入格式错误和服务器不可用,都可能返回非零,却需要不同处理。决定重试、忽略或终止之前,应先阅读目标命令的契约。不能只因为状态不为零,就把所有结果归为同一种事故。 让状态始终对应原来的命令 Bash 通过 $? 提供最近前台命令的状态。需要保留时,必须在记录日志、执行另一个条件判断或清理操作之前保存;这些操作也有自己的状态,会覆盖原本想观察的证据。函数通常使用最后执行的命令状态作为返回值,因此,末尾一句成功的日志输出就可能把前面的失败包装成整个函数成功,调用者看到的结果与实际工作脱节。 当命令成功与否决定后续动作时,可以使用 if command; then ... else ... fi。如果需要精确退出码,在 else 分支第一步保存 $?,输出诊断或清理后再通过 return 或 exit 显式传播。不要写成 if ! command,却期待进入成功分支后的 $? 仍然是原始失败码:逻辑取反已经把非零结果转换成成功,数值含义在这里发生了变化。 Bash 在找不到命令时使用一百二十七,找到却无法执行时使用一百二十六。因致命信号终止时,它使用一百二十八加信号编号的约定。这些规则有助于理解 shell 的报告,但应用也可以主动返回相同数字。因此,只有一个状态值还不足以证明内部具体发生了哪件事;应同时保留失败操作的身份和标准错误信息,避免根据数字臆造故障原因。 坏掉的样子 管道默认使用最后一段的退出状态。上游生成器失败时,下游仍可能成功读取一个空流或不完整数据,从而让整体看起来成功。开启 pipefail 后,Bash 返回从左到右最靠右的非零状态;全部成功才为零。PIPESTATUS 能提供每一段的结果,但后续命令同样会覆盖这个数组,所以必须先完整保存观察结果,再进行打印或判断。 set -e 开启的 errexit 具有由语法和调用上下文决定的例外。作为 if 条件的失败、与或列表中的部分命令,以及其他文档规定的场景,都不会自动结束脚本。函数被用作条件时,函数体中的失败命令之后也可能继续执行。ERR trap 也有类似排除规则,并不是每次失败都会产生的通知机制。两者都不能证明整个脚本已经可靠处理所有错误。 ...

三月 27, 2027 · 1 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现函数参数与共享状态。

Shell 函数像脚本一样读取自己的参数

Shell 函数把一组命令组织到同一个名称之下,并接收本次调用自己的位置参数。普通调用时,函数体在当前 Shell 中执行,不会自动启动独立进程。这方便复用操作,也意味着赋值、切换目录及其他 Shell 状态变化可能影响调用方。函数接口除了规定输入,还应明确输出、退出状态和有意产生的副作用,不能只因为把几行命令包进了函数,就认为它们已经与外层隔离。 每次调用都有自己的参数列表 调用 demo_func first second 后,这次函数内部的前两个位置分别是传入的两个值,参数数量为二。再次调用其他函数,会暂时安装下一层自己的参数列表;返回后,调用者的列表重新可见。因此,在被调用函数中移位,不会消耗调用者的位置参数。Bash 的 $0 也不会因此变成当前函数名称,不能用它判断自己正在哪个函数里面。 使用 "$@" 转发整份列表,才能保留空参数、空格以及通配符字符各自的边界。应先检查数量,再读取必需的位置,同时区分省略参数与明确传入空字符串。Shell 函数没有自动执行这些规则的参数声明,调用时语法上能写出一串值,并不意味着这些值已经满足函数的业务约定。 局部变量使用动态作用域 Bash 的局部声明会创建一个变量,遮住外层的同名绑定;函数返回之后,原来的绑定再次可见。若修改调用者变量并不是接口的一部分,应为临时值、循环计数器等声明局部变量。面向多个解释器的可移植脚本,不能假设每个 Shell 都实现了 Bash 的这套局部变量设施,解释器要求必须与使用的语法保持一致。 但 Bash 采用动态作用域:被另一个函数调用的函数,可以看到调用者已经声明的局部变量。如果它没有声明自己的局部变量,就直接给同名变量赋值,修改的可能正是调用者的局部绑定。最外层的全局值依然受保护,但中间那层函数的值已经改变。因此,局部变量不是其他函数完全不能接触的词法私有对象,沿真实调用链测试才看得见这种影响。 普通的局部变量声明不会自动恢复工作目录、Shell 选项、陷阱,或函数体已经改写的文件。Bash 另外提供 local -,可以在函数返回时恢复通过 set 改动的选项;这是一项明确的选项作用域机制,与单纯声明临时变量不同。这些效果都需要各自的约定。如果把函数放进普通命令替换,它会在子 Shell 环境中运行,变量和目录变化不会回到外层,但文件写入仍可能保留。捕获输出改变的是执行边界,不是为任意操作提供一套安全沙箱。 状态与数据走不同通道 没有显式返回时,函数通常采用最后执行命令的状态。标准输出应承载约定的数据,诊断则写到标准错误。return 7 把一个状态交给调用者,不是返回文本数字作为数据;exit 会结束正在执行的 Shell,而不只是离开当前函数。设计辅助函数时,需要让调用者知道究竟应读取哪个通道,以及失败后还能不能使用已经打印出的部分结果。 ...

三月 16, 2027 · 1 分钟 · XB Field Notes
内核里把 I/O 合并与重排、以换取更高吞吐或更公平的内核队列。

磁盘调度器给 I/O 排好队,让磁头移动更省

blk-mq 给块层准备了两级队列:每个 CPU 一个软件暂存队列,每条设备队列一个硬件派发队列,而 I/O 调度器就夹在两者之间。一个请求会走最短路径——直接进硬件队列——除非该设备挂了调度器,或者这个请求还能被合并;这两种情况下它要先在软件队列里停留。调度器是整个栈里唯一由内核决定“请求以什么顺序到达设备”的地方。 排序在机械盘上价值极大,在闪存上几乎为零,但调度器还有第二件活,而且这件事在 NVMe 时代依然存在:决定谁先等、谁后等。这两件事的方向并不一致——为磁头效率而重排根本不关心公平,而公平本身要付出寻道的代价——所以当前内核里的四种调度器不是拿来排名的,而是拿来匹配设备与负载的。 磁头才是预算 在 7200 RPM 的盘上,平均每个请求的旋转开销是 4.17 ms,传输一个 4 KiB 块只要 0.026 ms,所以几乎全部服务时间都是寻道加旋转。fixture 重放了 2000 个随机 4 KiB 读、覆盖 100000 条磁道:按到达顺序派发时磁头走过 67907411 条磁道、整轮耗时 21954 ms,约 91 IOPS;按磁道升序派发时只走 99977 条、耗时 8645 ms,约 231 IOPS。同样的请求、同样的设备,磁头行程少了 679 倍,吞吐涨了 2.5 倍。 ...

三月 9, 2027 · 1 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现文本捕获与退出状态。

命令替换把一条命令的输出嵌进另一行

命令替换会收集一个命令的标准输出,再把得到的文本放入外层表达式。常见的 $(command) 适合获取时间戳、计算出的标签或其他较小的文本值,但它并不是任意字节的透明传输通道,也不是可靠的文件名列表容器。使用前需要理解三个边界:末尾换行如何处理、内部命令在哪个执行环境运行,以及失败状态如何传递。只看到变量里有内容,不能证明整个操作成功。 先收集文本,再决定怎样使用 本文讨论的标准命令替换形式会让 Bash 在子 shell 环境中运行内部命令,收集标准输出,并删除末尾所有换行符。位于文本中间的换行仍然保留。双引号能阻止后续分词和文件名展开,却不能恢复已经删除的尾部换行。如果需要精确保留最后几个字节,应该使用文件或数据流,而不是依靠 shell 变量原样往返。 赋值语句 result=$(producer) 会把处理后的文本存进变量,不在此处拆成多个参数。后续使用时仍应写成 "$result",保留它的参数边界。类似地,consumer "$(producer)" 会传递一个参数,即使结果为空。去掉引号可能得到多个参数,也可能完全没有参数;输出中的星号还可能匹配当前目录的文件,让同样的生产者输出在不同目录产生不同调用结果。 标准错误和标准输出彼此独立,除非主动重定向,诊断信息仍可能显示在终端,而变量只收集结果正文。将两路输出合并后,警告文本可能污染本应供程序读取的值。因此,应先确定生产者的输出契约,再决定是否合并;直接吞掉所有诊断信息也可能让空结果失去可解释的原因。输出内容、诊断信息和退出状态应分别检查。 不要把这里的作用域结论推广到较新 Bash 的每一种捕获语法。本文使用的美元符号加圆括号形式具有实验验证的子 Shell 行为;较新的当前环境捕获形式会保留不同的副作用。说明一个捕获值时,应明确语法与解释器版本,而不是把所有能够取得文本的写法都视为等价。 坏掉的样子 子 shell 内修改变量或执行 cd,不会改变调用者对应的变量和工作目录。但文件系统或网络副作用并不会因此消失:在命令替换里创建的文件是真实文件,发出的请求也是真实请求。执行环境隔离不能被误解为事务回滚,也不能保证内部操作没有外部影响。 有输出也不代表生产者成功。仅包含赋值的命令会使用最后一个命令替换的退出状态,因此 if result=$(producer); then 可以检查生产者结果。相反,printf '%s' "$(producer)" 的状态通常来自 printf,而不是内部命令。生产者可以先输出部分内容,再失败退出;应立即保存退出码,并明确失败时是否允许使用这些不完整内容。后面的打印日志命令会覆盖 $? 所指向的最近状态。 多条命令组成的替换尤其需要明确失败传播:前一步失败后,最后一步仍可能成功,从而使整个替换返回成功。重要操作应在生产者内部显式判断,并返回预期状态。函数中如果需要检查捕获赋值的状态,也应把 local 声明和赋值分成两步,避免声明内建命令掩盖生产者退出码。不能仅凭脚本开启了某个全局选项,就假设每种上下文都会按同样方式中止。 ...

三月 3, 2027 · 1 分钟 · XB Field Notes
文章标题旁排列着由箭头相连的 OBSERVE、DECIDE、ACT 方框,下方有圆形指针和引导线。

文件描述符标识进程持有的打开引用

文件描述符是一个进程内部用于引用已打开资源的小整数。进程的描述符表把这个数字映射到内核维护的打开文件描述;对于普通文件,打开描述包含当前偏移和文件状态标志。另一个进程可以把相同整数用于完全不同的资源,因此数字本身不是全局文件身份。理解描述符表、打开描述与底层文件之间的关系,才能正确判断复制、关闭和继承的效果。 坏掉的样子 dup 分配另一个描述符,让它引用同一份打开文件描述;它不是简单复制整数,也不是重新按路径打开文件。因此,两份描述符读取普通文件时共享偏移。相反,再次打开相同路径,通常会得到具有独立偏移的新打开描述。设备与 inode 身份相同,不代表打开状态也相同,排查读取位置异常时必须区分这两个层面。 状态标志和描述符标志也属于不同层次。通过 F_SETFL 改变共享打开描述的状态,复制出来的引用都会看到;close-on-exec 标志则分别属于每个描述符。把这些层次混在一起,可能改变另一个消费者的行为,也可能让描述符意外进入后续执行的程序。选择复制引用之前,需要知道自己想独立控制的是寿命、偏移,还是继承行为。 关闭描述符会释放表项,整数可能立即被再次使用。只保留旧数字的代码,之后可能操作新资源,而不是稳定得到“无效描述符”错误。当描述符在回调、线程或库之间传递时,需要明确所有权和寿命,不能仅检查一个整数非负,就认定它仍然代表原来那个对象。数字看起来没有变化,引用关系却可能已经完全不同。 快速验证 在一次性 Linux 环境中运行下面的 Python 脚本。它创建两份无害临时文件,并关闭自己打开的所有描述符。实测环境为 Linux 6.12.76、Python 3.11.2。实验没有后台线程,因此可以观察分配与复用,不会有另一个线程同时抢占刚释放的编号: from pathlib import Path from tempfile import TemporaryDirectory import fcntl,os with TemporaryDirectory(prefix='xbcatm-fds-') as directory: root=Path(directory);original=root/'original';other=root/'other' original.write_bytes(b'abcdef');other.write_bytes(b'replacement') handles=set() try: first=os.open(original,os.O_RDONLY);handles.add(first) duplicate=os.dup(first);handles.add(duplicate) independent=os.open(original,os.O_RDONLY);handles.add(independent) assert first!=duplicate assert os.read(first,2)==b'ab' and os.read(duplicate,2)==b'cd' assert os.read(independent,2)==b'ab' assert os.lseek(first,0,os.SEEK_CUR)==4 print('dup uses a different descriptor but shares offset; a separate open has its own offset') flags=fcntl.fcntl(duplicate,fcntl.F_GETFL) fcntl.fcntl(duplicate,fcntl.F_SETFL,flags|os.O_NONBLOCK) assert fcntl.fcntl(first,fcntl.F_GETFL)&os.O_NONBLOCK assert not fcntl.fcntl(independent,fcntl.F_GETFL)&os.O_NONBLOCK os.set_inheritable(first,False);os.set_inheritable(duplicate,True) assert not os.get_inheritable(first) and os.get_inheritable(duplicate) print('file status flags shared by dup; close-on-exec flags remain descriptor-specific') os.close(first);handles.remove(first) reused=os.open(other,os.O_RDONLY);handles.add(reused) assert reused==first and os.read(first,32)==b'replacement' assert os.read(duplicate,2)==b'ef' print('closed descriptor number is reused for another file; surviving duplicate still reads original') original.unlink();os.lseek(duplicate,0,os.SEEK_SET) assert os.fstat(duplicate).st_nlink==0 and os.read(duplicate,32)==b'abcdef' print('unlink removes the pathname while an open descriptor retains access') finally: for handle in handles:os.close(handle) 第一次打开读取 ab,复制描述符接着读取 cd,两者此时都看到偏移四。对同一个文件独立执行 open,则仍从自己的起始位置读到 ab。这些精确字节证明哪份状态被共享;仅仅看到两个入口都能读取相同内容,并不足以区分复制引用和独立打开,因为它们本来就可以指向同一个底层文件。 ...

二月 21, 2027 · 1 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

EnvironmentFile 在 systemd 执行进程时供应取值

环境文件把部署取值与描述进程监管的单元定义分开。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 替换本应产生的那个文件,也确实没有出现。实验同时验证了优先级与解析边界,而不是只展示几行配置样子。 ...

二月 19, 2027 · 2 分钟 · XB Field Notes