观察、决策、行动三个方框,旁边提示安排启动并检查工作结果。

systemd timer 安排服务启动,不保证任务成功

systemd timer 把调度规则与执行工作的服务分开。定时器决定何时请求激活,服务则定义命令、执行身份、环境、超时和结果。因此,维护任务可以使用普通单元工具检查,但一个按时发出的启动请求,并不能证明备份、清理或报表已经正确完成。调度是否运转与工作是否成功,需要不同证据;只有两者都符合要求,周期任务才真正达到目的。 坏掉的样子 活动的定时器可以与失败的服务同时存在。如果监控只检查 timer 是否活动,就会漏掉真正执行工作的失败。同样,最近触发时间只说明调度发生过,不代表业务输出成功。应检查服务结果,以及任务本来要生成的产物或状态变化,并保留足够上下文区分前后两次激活,避免把昨天留下的文件误当成今天任务已经完成。 除非通过 Unit 指向别的目标,定时器通常激活同名的 service。如果目标服务已经活动,就不会再启动另一份独立副本。这阻止的是同一个单元重叠激活,不是分布式锁,也不能阻止另一个服务执行相同命令。oneshot 如果使用 RemainAfterExit=yes 保持活动,后续计划执行就可能一直不再运行命令;保留状态是否合适,取决于任务真正需要表达什么。 停止定时器会停止后续调度,但不会自动停止已经开始的服务调用。事故期间暂停维护工作时,这个区别尤其重要:需要检查目标服务,再有意决定让当前工作结束还是中断它。即使停止命令成功,先前已经发生的文件或数据库修改也不会因此自动回滚,因此不能把“停了 timer”当成系统已经没有维护副作用。 快速验证 在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。实测环境是 Debian 12、systemd 252.39。它创建唯一命名的定时器与服务,以及临时工作程序。工作程序记录真实进程进入和完成时刻,成功任务则通过显式释放文件等待,以便直接观察调度行为: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,time,uuid name='xbcatm-timer-'+uuid.uuid4().hex[:8];service=name+'.service';timer=name+'.timer' paths={unit:Path('/run/systemd/system')/unit for unit in (service,timer)} assert all(not p.exists() for p in paths.values()) def run(*args): result=subprocess.run(args,text=True,capture_output=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def value(unit,key):return run('systemctl','show',unit,'-p',key,'--value') def wait_for(check): deadline=time.monotonic()+5 while not check(): assert time.monotonic()<deadline,'transition timed out' time.sleep(0.02) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py' worker.write_text(r'''from pathlib import Path import json,sys,time root=Path(__file__).parent;number=len(list(root.glob('start-*.json')))+1 def record(kind): temp=root/'record.tmp';temp.write_text(json.dumps(time.monotonic())) temp.replace(root/(kind+'-'+str(number)+'.json')) record('start') if (root/'fail').exists():sys.exit(7) while not (root/('release-'+str(number))).exists():time.sleep(0.01) record('end') ''') paths[service].write_text(f'[Service]\nType=oneshot\nTimeoutStartSec=10s\nExecStart=/usr/bin/python3 {worker}\n') definition='[Timer]\nOnActiveSec=100ms\nOnUnitInactiveSec=10s\nAccuracySec=1ms\n' paths[timer].write_text(definition);(root/'fail').touch() try: run('systemd-analyze','verify',*[str(p) for p in paths.values()]) run('systemctl','daemon-reload');run('systemctl','start',timer) wait_for(lambda:value(service,'ActiveState')=='failed') assert value(service,'ExecMainStatus')=='7' and value(timer,'ActiveState')=='active' run('systemctl','stop',timer) print('timer active while its first job failed with exit status 7') (root/'fail').unlink();run('systemctl','reset-failed',service) paths[timer].write_text(definition.replace('OnUnitInactiveSec=10s','OnUnitInactiveSec=250ms')) run('systemctl','daemon-reload');run('systemctl','start',timer) wait_for(lambda:(root/'start-2.json').exists()) time.sleep(0.65) assert len(list(root.glob('start-*.json')))==2 (root/'release-2').touch();wait_for(lambda:(root/'start-3.json').exists()) gap=json.loads((root/'start-3.json').read_text())-json.loads((root/'end-2.json').read_text()) assert gap>=0.25,gap print('no overlapping launch; next job starts at least 250ms after prior deactivation') pid=value(service,'MainPID');assert pid!='0' run('systemctl','stop',timer) assert value(service,'MainPID')==pid and value(service,'ActiveState')=='activating' print('stopping timer leaves its already running oneshot alive') (root/'release-3').touch();wait_for(lambda:value(service,'ActiveState')=='inactive') time.sleep(0.4) assert len(list(root.glob('start-*.json')))==3 and value(service,'Result')=='success' print('released job completes; stopped timer schedules no further run') finally: run('systemctl','stop',timer,service) for unit,path in paths.items(): if value(unit,'ActiveState')=='failed':run('systemctl','reset-failed',unit) path.unlink() run('systemctl','daemon-reload') 第一次触发的工作进程故意以状态七退出,服务变为失败,定时器却继续活动。此时相对服务结束的下一次间隔先设置为十秒,让脚本有时间检查这个状态,并在再次尝试前停止定时器。结果证明调度器完全可能正常工作,而它安排的业务任务一直失败;仅凭 timer 状态不能得出任务可靠的结论。 ...

六月 21, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边提示创建缺少的服务账号。

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
三个相连方块,依次标有 Observe、Decide 和 Act。

Linger 延长 systemd 用户管理器在登录之外的寿命

Linger 改变的是一个账号的 systemd 用户管理器在什么时候保持可用。启用后,logind 可以在开机时启动该管理器,并在该账号登录会话结束后继续保留它,从而支持普通账号拥有的无人值守工作。这个标志控制管理器寿命,各个服务仍然遵守自己的激活、失败、停止和安装规则。账号可以没有登录会话,同时有管理器运行,但这不等于所有应用都已经启动。 坏掉的样子 开启 linger 不会自动启用账号配置目录里的每一个单元文件。服务仍需要通过启用关系、依赖、定时器、套接字或其他明确路径受到激活。因此管理器可能完全正常,目标应用却保持非活动。应同时检查两者,而不能把一个 linger 标志理解成当前所有服务的运行清单,更不能仅凭它存在就认定某个后台端口已经提供业务响应。 进程不会跨过整台机器重启继续存活。Linger 可以让新的用户管理器在开机时启动,已启用单元再创建新的进程,原来的内存状态已经消失。加密家目录或较晚挂载的路径,也可能让配置和应用数据在管理器启动时不可用。需要保留的状态应持久化,开机依赖应明确安排,不能靠“会跨重启”这种模糊说法跳过实际恢复过程。 Linger 也不是让崩溃应用无限重启的指令,不会自动把任意 shell 后台任务纳入服务管理。重启策略、资源限制和 logind 对会话进程的处理,分别属于不同控制。稳定的后台角色需要真实用户单元和可观察结果,不能只依赖登出前启动过一个命令,再把账号管理器继续存在当成那个命令必然受控运行。 快速验证 在具备 systemd、logind、用户 D-Bus 支持与账号管理工具的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。实测环境为 Debian 12、systemd 252.39。它创建临时账号和家目录,只为该账号开启 linger,最后回收本次改动,全程不为这个账号建立登录会话: from pathlib import Path from tempfile import TemporaryDirectory import os,pwd,subprocess,time,uuid name='xbcatm-linger-'+uuid.uuid4().hex[:8];unit='field-worker.service' def run(*args): result=subprocess.run(args,capture_output=True,text=True,timeout=20) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def wait_for(check,seconds=5): deadline=time.monotonic()+seconds while not check(): assert time.monotonic()<deadline,'state transition timed out';time.sleep(0.05) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);root.chmod(0o755);home=root/'home';home.mkdir() run('useradd','--no-create-home','--user-group','--home-dir',str(home),'--shell','/bin/sh',name) account=pwd.getpwnam(name);uid=account.pw_uid;gid=account.pw_gid;manager=f'user@{uid}.service' def userctl(*args): return run('setpriv','--reuid='+str(uid),'--regid='+str(gid),'--init-groups', 'env',f'XDG_RUNTIME_DIR=/run/user/{uid}',f'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus', 'systemctl','--user',*args) def manager_value(key):return run('systemctl','show',manager,'-p',key,'--value') try: units=home/'.config/systemd/user';units.mkdir(parents=True) (home/'worker.py').write_text("from pathlib import Path\nimport os,time\np=Path(__file__).with_name('worker-pid');t=p.with_suffix('.tmp');t.write_text(str(os.getpid()));t.replace(p)\ntime.sleep(60)\n") (units/unit).write_text('[Service]\nType=exec\nExecStart=/usr/bin/python3 %h/worker.py\n[Install]\nWantedBy=default.target\n') for path in [home,*home.rglob('*')]:os.chown(path,uid,gid) run('loginctl','enable-linger',name) wait_for(lambda:Path(f'/run/user/{uid}/bus').exists()) assert run('loginctl','show-user',name,'-p','Linger','--value')=='yes' assert run('loginctl','show-user',name,'-p','Sessions','--value')=='' assert manager_value('ActiveState')=='active' assert userctl('show',unit,'-p','ActiveState','--value')=='inactive' print('linger starts a manager with zero login sessions; merely installing a user unit does not start it') userctl('enable','--now',unit);wait_for(lambda:(home/'worker-pid').exists()) first=(home/'worker-pid').read_text();first_manager=manager_value('MainPID') run('systemctl','restart',manager) wait_for(lambda:Path(f'/run/user/{uid}/bus').exists() and (home/'worker-pid').read_text()!=first) assert manager_value('MainPID')!=first_manager assert userctl('show',unit,'-p','MainPID','--value')==(home/'worker-pid').read_text() assert run('loginctl','show-user',name,'-p','Sessions','--value')=='' print('enabled user service starts as a new process after a real manager restart') userctl('disable',unit) assert userctl('show',unit,'-p','ActiveState','--value')=='active' print('disable removes future enablement but does not stop the current service') userctl('stop',unit);run('loginctl','disable-linger',name) wait_for(lambda:manager_value('ActiveState')=='inactive',seconds=20) print('with no sessions and linger disabled, user manager stops after policy delay') finally: run('loginctl','disable-linger',name) run('systemctl','stop',manager,f'user-runtime-dir@{uid}.service') run('userdel',name) 执行启用后,用户管理器变为活动,其总线端点出现,logind 报告 linger 已开启、会话列表为空。但已经安装的工作单元仍然非活动。这证明让管理器保持可用,与请求某个应用运行,是不同操作;即使文件已经出现在正确配置目录中,也还需要符合实际契约的激活入口。 ...

四月 20, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边提示分别检查时钟与服务状态。

分别检查 timesyncd 服务状态与时钟证据

systemd-timesyncd 是一个精简 SNTP 客户端,用配置的时间源同步本地系统时钟。它可以帮助证书检查与墙钟时间戳保持有意义,但不会无条件保证某个精度范围。守护进程能否运行、是否收到可接受样本,以及系统是否报告同步,是需要分别确认的事实。只看到安装了客户端,或者某条命令成功返回,都不足以推导时钟已经准确。 坏掉的样子 守护进程并不总是平滑校正。文档明确说明,大偏差会直接跳时,小偏差则逐渐调整。因此,开机时离正确时间很远的机器,可能发生时间不连续。经过时长测量应使用单调时钟,解释墙钟时间戳的应用则应处理校时,而不是假定时间永远平滑前进。不能把“精简客户端”误写成“绝不会让应用看见时间跳跃”。 FallbackNTP 不是显式服务器不可达时就一定会启用的通用应急列表,它在没有其他已知服务器信息时才适用。全局 NTP 条目和 networkd 提供的链路时间源,优先于这个后备设置。因此,应检查实际来源选择与配置覆盖文件,不能仅因为主来源连接失败,就断定某个写在 fallback 中的服务器必定已经被尝试。 未同步也不能证明 UDP 流量被阻止。服务可能缺失、未启用、被单元条件排除、没有调整时钟的权限,或者正在拒绝不合适样本。反过来,全局同步属性也不能单独说明哪个进程提供了时间。应区分管理器看到的服务状态、客户端观察到的样本,以及内核层同步指示,避免把来自不同层面的一个布尔值当成完整故障结论。 快速验证 这个失败模式实验在一次性 Debian 12 Docker 容器中以 root 运行,PID 1 为 systemd 252.39。初始化进程通过 /usr/bin/setpriv --bounding-set=-sys_time,-sys_module /sbin/init 启动,移除相关能力以防止调整 Docker 虚拟机共享时钟。脚本在请求任何服务启动前检查该前提,并要求安装包中的容器排除条件存在: from pathlib import Path import json,subprocess def run(*args): result=subprocess.run(args,text=True,capture_output=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def properties(unit): output=run('systemctl','show',unit,'-p','ActiveState','-p','SubState','-p','MainPID','-p','ConditionResult') return dict(line.split('=',1) for line in output.splitlines()) # This failure-mode exercise requires an isolated container that cannot set time. status=dict(line.split(':',1) for line in Path('/proc/1/status').read_text().splitlines() if ':' in line) assert int(status['CapBnd'].strip(),16)&(1<<25)==0,'PID 1 must lack CAP_SYS_TIME' assert run('systemd-detect-virt','--container')=='docker' unit='systemd-timesyncd.service';definition=run('systemctl','cat',unit) assert 'ConditionCapability=CAP_SYS_TIME' in definition assert 'ConditionVirtualization=!container' in definition assert properties(unit)['ActiveState']=='inactive' run('systemctl','start',unit) state=properties(unit) assert state=={'MainPID':'0','ActiveState':'inactive','SubState':'dead','ConditionResult':'no'},state clock=dict(line.split('=',1) for line in run('timedatectl','show','-p','CanNTP','-p','NTP','-p','NTPSynchronized').splitlines()) assert all(value in ('yes','no') for value in clock.values()) print('start command returned zero; unit stayed inactive with PID 0 and failed conditions') print('clock properties:',json.dumps(clock,sort_keys=True)) print('PID 1 lacks time-setting capability; Docker condition also excludes this packaged service') print('no clock-setting command was issued and no NTP synchronization was claimed') 启动命令返回零,但单元仍为非活动,主进程编号为零,ConditionResult=no。条件不满足而跳过激活,与守护进程成功运行,是不同结果。样本安装包要求调整时间的能力,并排除容器,而当前环境两项前提都不满足。因此在假设 NTP 报文被网络丢弃之前,就已经有具体证据解释服务为何没有工作。 ...

三月 11, 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
观察、决策、行动三个方框,旁边提示多个会话共享同一个用户管理器。

同一账号的会话共享 systemd 用户管理器

systemd 用户管理器代表一个账号运行服务。同一账号的多个登录会话通常共享这个管理器,而不是每个 shell 都拥有一套私有服务世界。用户单元可以放在 ~/.config/systemd/user,通过 systemctl --user 操作对应管理器。这提供的是以账号权限执行的生命周期管理,并不自动隔离所有共享系统资源。使用它之前,需要同时理解服务属于谁,以及管理器在什么条件下继续存在。 坏掉的样子 关闭一个终端,不一定停止用户服务,因为另一个登录会话可能仍然存在,管理器寿命也受到 linger 和停止延迟配置影响。即使最后一个会话结束,立即终止也不是普遍规则。必须跟随某一次图形会话结束的应用,需要明确的生命周期关系,不能假定任何用户单元天然具有这个范围。账号级后台任务与某个窗口的寿命,是不同需求。 选错管理器也是常见故障。普通 systemctl 操作系统管理器,systemctl --user 则需要连接正确用户的管理器及其运行时通信端点。通过另一身份执行命令,或者所在环境没有完成会话准备,都可能导致连接失败。仅仅设置一个环境变量,不会创建管理器,也不会授予控制其他账号的权限;诊断时应先确认执行身份与目标总线是否一致。 用户服务也不会自动继承发出控制命令的那个终端环境。所需路径、配置和图形会话变量,应通过有意选择的机制提供。使用普通账号会减少权限,但该账号仍可以按既有权限访问共享可写文件、使用网络套接字并消耗资源。因此“没有 root”并不等于“所有影响都绝对局限于自己的文件”,还要检查真实权限和资源限制。 快速验证 在具备 systemd、logind、PAM 集成、用户 D-Bus 会话与账号管理工具的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录环境为 Debian 12、systemd 252.39。脚本创建临时账号、两个真实 PAM 会话和一个用户服务,不修改任何已有人员的单元或登录状态: from pathlib import Path from tempfile import TemporaryDirectory import os,pwd,subprocess,time,uuid name='xbcatm-user-'+uuid.uuid4().hex[:8];sessions=[name+'-a.service',name+'-b.service'];unit='field-worker.service' def run(*args): result=subprocess.run(args,capture_output=True,text=True,timeout=20) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def wait_for(check,seconds=5): deadline=time.monotonic()+seconds while not check(): assert time.monotonic()<deadline,'state transition timed out';time.sleep(0.05) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);root.chmod(0o755);home=root/'home';home.mkdir() run('useradd','--no-create-home','--user-group','--home-dir',str(home),'--shell','/bin/sh',name) account=pwd.getpwnam(name);uid=account.pw_uid;gid=account.pw_gid;manager=f'user@{uid}.service' def userctl(*args): return run('setpriv','--reuid='+str(uid),'--regid='+str(gid),'--init-groups', 'env',f'XDG_RUNTIME_DIR=/run/user/{uid}',f'DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/{uid}/bus', 'systemctl','--user',*args) def manager_value(key):return run('systemctl','show',manager,'-p',key,'--value') try: units=home/'.config/systemd/user';units.mkdir(parents=True) (home/'worker.py').write_text("from pathlib import Path\nimport os,time\np=Path(__file__).with_name('worker-pid');t=p.with_suffix('.tmp');t.write_text(str(os.getpid()));t.replace(p)\ntime.sleep(60)\n") (units/unit).write_text('[Service]\nType=exec\nExecStart=/usr/bin/python3 %h/worker.py\n[Install]\nWantedBy=default.target\n') for path in [home,*home.rglob('*')]:os.chown(path,uid,gid) for session in sessions: run('systemd-run','--quiet','--unit='+session,'--property=Type=simple', '--property=User='+name,'--property=PAMName=login','/usr/bin/sleep','60') wait_for(lambda:Path(f'/run/user/{uid}/bus').exists()) wait_for(lambda:len(run('loginctl','show-user',name,'-p','Sessions','--value').split())==2) assert run('loginctl','show-user',name,'-p','Linger','--value')=='no' initial=manager_value('MainPID');assert initial!='0' userctl('enable','--now',unit);wait_for(lambda:(home/'worker-pid').exists()) worker_pid=(home/'worker-pid').read_text() assert userctl('show',unit,'-p','MainPID','--value')==worker_pid run('systemctl','stop',sessions[0]) wait_for(lambda:len(run('loginctl','show-user',name,'-p','Sessions','--value').split())==1) assert manager_value('MainPID')==initial and userctl('show',unit,'-p','MainPID','--value')==worker_pid print('two PAM sessions share one user manager; closing one leaves its service unchanged') run('systemctl','stop',sessions[1]) wait_for(lambda:manager_value('ActiveState')=='inactive',seconds=20) assert not Path('/proc/'+worker_pid).exists() print('after the final session and configured stop delay, non-lingering manager and worker stop') print('controllers use setpriv and the actual user bus; they do not create extra PAM sessions') finally: for session in sessions: if run('systemctl','show',session,'-p','LoadState','--value')!='not-found':run('systemctl','stop',session) run('systemctl','stop',manager,f'user-runtime-dir@{uid}.service') run('userdel',name) 两个临时会话启动器使用相同账号,并设置 PAMName=login。脚本等待 logind 报告两个会话、且用户总线可用后,才继续控制服务。启动器使用 simple 类型,因此命令返回不会被当成 PAM 已完成的证据;会话数量和总线状态提供了明确的观察边界,避免把尚在建立的登录环境当成已经可用。 ...

一月 26, 2027 · 2 分钟 · XB Field Notes
观察、决策与行动三块面板,旁边是关于只读根文件系统边界的标题。

只读根在其范围内限制文件系统修改

只读根文件系统限制对服务打包代码和配置所在文件系统层的普通写入,明确挂载仍可提供运行数据的可写位置。这能减少意外修改和部分持久化机会,但边界是文件系统变更本身。它不会单独移除进程权限、阻止读取可访问数据,也不会禁止与其他服务通信,需要按实际作用范围理解其价值。 坏掉的样子 应用经常在主要数据目录之外写入临时文件、缓存、锁文件、生成配置或运行状态。只读根会让这些假设在启动或不常见操作中以 EROFS 暴露。只测健康检查端点,可能漏掉生成报表、刷新缓存或关停清理时才经过的写入路径。因此必须沿真实工作流程检查需求,不能把进程成功启动当成全部功能可用。 即使容器根层只读,可写挂载仍然可写,其中状态仍可能被故障或受损进程改坏。把大块可写目录覆盖到打包路径,也可能抵消这些路径原本需要的保护。应检查最终挂载布局,而不是根据一个根层选项推断整棵路径树的行为。路径看起来位于只读根之下,并不证明中间没有另一个可写挂载。 只读与保密也是不同属性。进程仍可能读取打包文件、执行已有程序,并通过允许的网络路径发送数据。该设置也不保证容器逃逸之后仍受相同保护,更不约束能够改变挂载安排的权限。应结合凭据、能力、隔离和应用行为理解它的贡献,避免把一项文件系统限制描述成完整安全边界。 快速验证 在一次性 Linux 容器内以 root 运行下面 Python 脚本。容器应使用只读根,在 /tmp 挂可写 tmpfs,在 /work 挂独立可写卷,镜像内预先安装 Python 3。实测使用 Linux 6.12.76 和 Python 3.11.2,没有宿主机绑定挂载、特权模式或外部网络: from pathlib import Path from tempfile import TemporaryDirectory import errno,os,socket,subprocess,sys,uuid assert os.geteuid()==0,'run inside a disposable read-only-root container as root' assert os.statvfs('/').f_flag&os.ST_RDONLY name='xbcatm-readonly-'+uuid.uuid4().hex for base in [Path('/'),Path('/usr'),Path('/etc')]: path=base/name try:fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) except OSError as error:assert error.errno==errno.EROFS,error else: os.close(fd);path.unlink();raise AssertionError('root filesystem accepted a new file') print('even this root process receives EROFS on paths in the read-only root, usr and etc tree') for base in ['/tmp','/work']: assert not os.statvfs(base).f_flag&os.ST_RDONLY with TemporaryDirectory(prefix='xbcatm-write-',dir=base) as directory: path=Path(directory)/'data';path.write_bytes(b'allowed');assert path.read_bytes()==b'allowed' path.write_bytes(b'changed');assert path.read_bytes()==b'changed' print('explicit tmpfs and data-volume mounts remain writable, including overwriting their contents') assert Path('/etc/os-release').read_text() result=subprocess.run([sys.executable,'-c','print("executed")'],capture_output=True,text=True,check=True) assert result.stdout.strip()=='executed' with socket.socket() as listener,socket.socket() as client: listener.bind(('127.0.0.1',0));listener.listen(1);client.connect(listener.getsockname()) connection,_=listener.accept() with connection: client.sendall(b'local');assert connection.recv(5)==b'local' print('reading packaged files, executing an existing program, and loopback network I/O still work') 脚本先检查根文件系统报告的只读标志,再尝试在根、usr 和 etc 下排他创建唯一名字的文件。即使采用 root 身份,三次都得到 EROFS。唯一名称与排他创建保证探测不会覆盖既有配置文件。结果来自当前文件系统真正拒绝操作,而不是只看配置中出现了某个只读选项。 ...

十二月 23, 2026 · 1 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边提示区分日历时刻与经过时长。

为 systemd 定时器选对时钟和补跑规则

一份计划必须先说清楚“时间”是什么意思。“纽约每天早上九点”指向民用日历,“任务结束十五分钟后”则指向经过时长和一个参考事件。systemd timer 同时支持这两类表达,但它们使用的时钟与起算点不同,在时区变化、系统休眠和定时器停用期间会表现出不同结果。只把它们统称为周期调度,会隐藏本来应该由需求决定的差别。 坏掉的样子 OnCalendar 按墙钟日历表达式触发,省略时区通常使用系统本地时区,因此同一份配置在不同机器上可能代表不同实际时刻。夏令时规则变化时,每天相同的本地时间也可能相对 UTC 移动。应使用固定参考日期预览预期表达式,而不是把今天的 UTC 偏移当成全年不变的事实。调度语法能解析,只证明表达式有效,不证明它符合业务要求的地区时间。 单调计时选项明确命名参考事件。OnActiveSec 从 timer 本身激活起算,OnStartupSec 从服务管理器启动起算,OnUnitInactiveSec 从目标单元结束活动起算。这些并不是“隔几分钟运行”的可互换写法。普通单调计时在系统休眠期间暂停,WakeSystem 会改变时钟选择,并依赖平台支持。因此是否计入休眠时间,也应该属于调度需求,而不是部署后的偶然发现。 Persistent=yes 对日历计时器生效。它记录触发时刻,在定时器恢复活动且至少错过一个事件时请求一次补触发;不会逐次重放所有错过的日历时刻,也不能证明上次业务执行成功。如果配置了随机延迟,补触发仍受其影响。触发历史不是一条持久业务队列,不能据此推定停机期间应做的所有工作都已完整保存。 快速验证 在 PID 1 为 systemd、安装了时区数据库的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录中的环境为 Debian 12、systemd 252.39。脚本先预览日历表达式,再安装两个唯一命名的定时器与服务组合,双方使用同样三个即将到来的日历时刻,全程不会调整机器时钟: from pathlib import Path from tempfile import TemporaryDirectory from datetime import datetime,timezone import subprocess,time,uuid name='xbcatm-calendar-'+uuid.uuid4().hex[:8] names=[name,name+'-plain'];paths=[] def run(*args): result=subprocess.run(args,text=True,capture_output=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() preview=run('systemd-analyze','calendar','--base-time=2026-03-07 00:00:00 UTC', '--iterations=3','*-*-* 09:00:00 America/New_York') for expected in ('2026-03-07 14:00:00 UTC','2026-03-08 13:00:00 UTC','2026-03-09 13:00:00 UTC'): assert expected in preview,preview print('09:00 New York: 14:00 UTC before spring DST, 13:00 UTC after it') with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py' worker.write_text("from pathlib import Path\nimport sys\np=Path(sys.argv[1])\nwith p.open('a') as output:output.write('run\\n')\n") now=int(time.time());events=[now+3,now+4,now+5] calendar=''.join('OnCalendar='+datetime.fromtimestamp(t,timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')+'\n' for t in events) try: for index,prefix in enumerate(names): service=Path('/run/systemd/system')/(prefix+'.service');timer=service.with_suffix('.timer') assert not service.exists() and not timer.exists() service.write_text(f'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 {worker} {root}/{index}.log\n');paths.append(service) timer.write_text('[Timer]\n'+calendar+'AccuracySec=1ms\nRandomizedDelaySec=0\nPersistent='+('yes' if index==0 else 'no')+'\n');paths.append(timer) run('systemd-analyze','verify',*[str(p) for p in paths]);run('systemctl','daemon-reload') run('systemctl','start',name+'.timer');run('systemctl','stop',name+'.timer') assert not (root/'0.log').exists() and time.time()<events[0] while time.time()<events[-1]+0.15:time.sleep(0.05) run('systemctl','start',name+'.timer',name+'-plain.timer') deadline=time.monotonic()+3 while not (root/'0.log').exists(): assert time.monotonic()<deadline,'catch-up did not run';time.sleep(0.02) time.sleep(0.15) assert (root/'0.log').read_text()=='run\n' and not (root/'1.log').exists() print('three elapsed calendar events: persistent timer catches up once; plain timer does not') run('systemctl','stop',name+'.timer');run('systemctl','start',name+'.timer') time.sleep(0.2);assert (root/'0.log').read_text()=='run\n' print('reactivating with no newly missed event does not replay the job') print('timer inactivity was tested without changing the system clock or rebooting') finally: if paths: run('systemctl','stop',*[p.name for p in paths]) run('systemctl','clean','--what=state',name+'.timer') for path in paths:path.unlink(missing_ok=True) run('systemctl','daemon-reload') 预览从二〇二六年三月夏令时切换前的固定日期开始,询问纽约早上九点的后续发生时刻。第一次对应 UTC 十四点,后两次对应 UTC 十三点,本地钟表时间却始终是九点。这检查的是日历解释,并不是修改主机时区,也不是把系统时间跳到那些日期。对指定地区的每日任务而言,这种偏移变化可能正是正确行为。 ...

十一月 20, 2026 · 2 分钟 · XB Field Notes