声明依赖以让初始化系统调度启动顺序

systemd 依赖要区分激活、顺序与就绪

依赖图需要表达的不只是“先启动数据库”。一种关系决定是否把另一个单元拉进本次任务,一种关系决定启动任务的先后,服务类型则决定怎样才算启动完成。应用就绪还是程序必须真正兑现的承诺。把这些层次混在一起,得到的开机流程可能看起来很有顺序,依赖方实际却仍然在所需条件成立之前就开始运行。 坏掉的样子 After=provider.service 在双方都有启动任务时,把当前单元排在提供方之后,但它不会主动请求启动提供方。如果本次只激活消费者,单靠这条排序关系,提供方完全可能继续保持非活动。只有消费者确实应当带着提供方一起启动时,才需要另外声明合适的激活依赖;“排在它后面”与“把它启动起来”不能互相替代。 Wants 会请求启动另一个单元,但容忍对方启动失败。Requires 表达更强的关系;与 After 配合时,如果提供方激活失败,依赖它的单元就不会启动。但这两个关键字各自都不会自动建立启动顺序。应根据服务的真实需要选择组合,而不是把所有可选组件都变成硬性前提,也不能把弱依赖误当作一定成功的就绪屏障。 有序启动的强度取决于提供方怎样报告完成。Type=exec 表示程序已经成功执行,不表示数据库完成初始化;Type=notify 可以把启动任务保持到程序发送 READY=1;oneshot 则可以等待有限的设置工作结束。应用必须在承诺的就绪条件真的满足后才发送通知,管理器无法替它判断某个业务表或外部依赖是否已经可用。 快速验证 在一次性的 Linux systemd 环境中,以管理员身份运行下面的完整 Python 脚本。它安装两个临时运行时单元,依次验证六种配置。记录中的环境为 Debian 12、systemd 252.39。确认两个实验单元名尚未使用;临时文件分别记录提供方是否进入程序、是否就绪,以及消费者实际观察到了什么: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,time provider='xbcatm-provider-lab.service';consumer='xbcatm-consumer-lab.service' paths={name:Path('/run/systemd/system')/name for name in (provider,consumer)} assert all(not p.exists() for p in paths.values()) def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10) assert (result.returncode==0)==success,(args,result.stderr) return result.stdout.strip() def value(name,key):return ctl('show',name,'-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-dependency-',dir='/run') as directory: root=Path(directory) (root/'provider.py').write_text(r"""from pathlib import Path import os,socket,sys,time root=Path(__file__).parent;mode=(root/'mode').read_text() if mode=='fail': sys.exit(7) (root/'entered').touch() if mode in ('exec','notify'): while not (root/'gate').exists(): time.sleep(0.01) (root/'ready').touch() if mode=='notify': address=os.environ['NOTIFY_SOCKET'] if address.startswith('@'): address='\0'+address[1:] with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as notification: notification.connect(address);notification.sendall(b'READY=1') if mode in ('exec','notify'): time.sleep(60) """) (root/'consumer.py').write_text("from pathlib import Path\nr=Path(__file__).parent\n(r/'observed').write_text(str((r/'ready').exists()))\n") def install(mode,requirement): (root/'mode').write_text(mode) kind='oneshot' if mode in ('oneshot','fail') else mode paths[provider].write_text(f'[Service]\nType={kind}\nExecStart=/usr/bin/python3 {root}/provider.py\nTimeoutStartSec=5s\n'+('RemainAfterExit=yes\n' if kind=='oneshot' else '')) paths[consumer].write_text(f'[Unit]\nAfter={provider}\n'+(f'{requirement}={provider}\n' if requirement else '')+f'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 {root}/consumer.py\n') subprocess.run(['systemd-analyze','verify',*[str(p) for p in paths.values()]],check=True) ctl('daemon-reload') def reset(): ctl('stop',consumer,provider) for name in paths: if value(name,'ActiveState')=='failed':ctl('reset-failed',name) for name in ('entered','gate','ready','observed'):(root/name).unlink(missing_ok=True) pending=None install('oneshot','') try: ctl('start',consumer) assert (root/'observed').read_text()=='False' and value(provider,'ActiveState')=='inactive' print('After alone: provider not pulled in; consumer ran before readiness') reset();install('fail','Wants');ctl('start',consumer) assert value(provider,'ActiveState')=='failed' and (root/'observed').read_text()=='False' print('Wants plus After: consumer runs despite provider failure') reset();install('fail','Requires');ctl('start',consumer,success=False) assert value(provider,'ActiveState')=='failed' and not (root/'observed').exists() print('Requires plus After: failed provider prevents consumer execution') reset();install('oneshot','Requires');ctl('start',consumer) assert (root/'observed').read_text()=='True' print('oneshot provider: consumer sees completed setup') reset();install('exec','Requires');ctl('start',consumer) assert (root/'observed').read_text()=='False' and value(provider,'ActiveState')=='active' (root/'gate').touch();wait_for(lambda:(root/'ready').exists()) print('Type=exec provider: process started before application readiness') reset();install('notify','Requires') pending=subprocess.Popen(['systemctl','start',consumer],text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE) wait_for(lambda:(root/'entered').exists()) assert pending.poll() is None and not (root/'observed').exists() assert value(provider,'ActiveState')=='activating' (root/'gate').touch();out,err=pending.communicate(timeout=10) assert pending.returncode==0,err assert (root/'observed').read_text()=='True' print('Type=notify provider: dependent waits for actual READY=1') finally: reset() if pending is not None and pending.poll() is None:pending.communicate(timeout=5) for path in paths.values():path.unlink() ctl('daemon-reload') 只有 After 时,消费者运行并记录尚未就绪,而提供方保持非活动。这不是提供方启动得慢,而是没有任何激活关系把它拉进来。使用 Wants 加 After 时,提供方故意以状态七退出,消费者仍然执行并记录否。样本程序接受这个观察结果;真实消费者如果确实必须依赖它,随后可能在自己的业务操作中失败,弱依赖不会替业务消除这个事实。 ...

二月 18, 2028 · 2 分钟 · XB Field Notes
按首个连接才拉起服务的按需监听钩子。

socket 激活让服务按需启动

端口永远就绪,而进程只在有人敲门时才起来,这正是少用却沉重的服务该有的妙招。每个闲置守护进程都在花钱:常驻内存、周期性唤醒、补丁面、日志噪音。一台机器上几十个不常用的服务加起来,税交了不少,事一件没干。套接字激活把交易反过来——内核拿着监听套接字,守护进程出生之前它就归系统服务管理器所有,第一个带着真活的连接到了,服务进程才出生。 交接的顺滑让一切透明。客户端照常连端口,不知道家里没人;管理器拉起服务,把已经接受的连接递进去,第一个客户端都不必等两遍。做对了,按需启动和常开除了账单看不出区别——除了首击延迟,这是唯一要诚实预算的成本。 让监听器先等着 systemd 在服务之前先把 socket 打开,第一个连接到来时才启动单元,并把收到的文件描述符交过去。把 ListenStream 配好一次,让 socket 在任何守护进程起来之前就先诞生并绑定好。套接字单元和服务单元按名配对,一个管监听、一个管干活;默认模式把整个监听套接字交给单个服务实例,按连接派生的模式则给简单请求处理配每连接一个实例。 分离还顺手解决了开机排序。不用写脆弱的先后依赖:套接字开机早期就在,客户端随便什么顺序起,连不上就等一等、重试一次,而不是直接报错。排队的连接在内核积压里等着,开机竞态变成了队列深度而不是错误。当然积压长度也要调:启动慢的服务遇上开机早期的连接洪峰,溢出起来和普通监听队列一样。 服务按需唤醒 空闲的守护进程可以一直停着,用每实例的内存与启动周期,换一次交接的开销。所以套接字激活的服务可以一直睡,直到第一个请求值得它跑起来。这套最适合管理工具、老协议网关、定期但罕见的任务——闲忙比按天算、不按分钟算的服务。 闲时退出补上闭环:服务空闲若干秒自己停,系统在零成本和满能力之间振荡,不用人管。套接字从不关闭,下一个客户端透明触发下一次启动。没有闲时退出,激活只省了开机内存,进程还是越积越多——按需启动加闲时停止成对出现,妙招才变成稳态节省。 用速度换内存 代价是第一次访问有一段短暂热身,这适合很少跑的任务,而对始终热呼的路径很浪费。在首次调用的延迟可接受、而内存节省真的有意义时才开启它。动手前两边都量化:从连接到首个响应的冷启动耗时,常开会永远占住的常驻内存。启动三百毫秒换八百内存,大胆开;预热二十秒换五十内存,算了。 有些负载无论如何都受不了这个间隙。靠健康检查编排的系统会在启动期判端点死亡,负载均衡会摘掉首个慢后端,超时卡得紧的客户端没等服务热完就失败了。这些就让守护进程常开,内存该花就花——或者开机时预热一次,激活只覆盖之后漫长的闲置。 坏掉的样子 标志性故障是惊群式启动:监控扫一轮,五十个睡着的服务同时被连醒,同时启动,主机内存告急、负载冲顶,服务大面积变慢。每个激活单独看都合理,合在一起就是踩踏。用错峰检查间隔、按服务的启动限制、足够短的闲时退出,把稳态重叠压小。测试激活要在监控负载下测,不只拿一条手工请求测。 第二种故障是半吊子适配:服务认定套接字归自己,启动时自己绑端口,和管理器已绑的套接字撞车。被激活的那一刻恰恰启动失败,连接挂住,日志怪地址已被占用。服务必须接受递进来的文件描述符而不是自己绑;上游代码改不动,就写个小垫片继承套接字再拉起守护进程过渡。 快速验证 端到端验证按需行为: systemctl status foo.socket foo.service ss -ltnp | grep ':8080' curl -m 10 localhost:8080/health; systemctl show foo.service -p ActiveState,ExecMainStartTimestamp 请求之前服务未激活、套接字在监听;请求之后时间戳显示一次由连接触发的新启动。 延伸阅读 重启策略需要一个速率限制 维护定时器应让其最近结果可见 来源 systemd.socket 手册,套接字单元官方参考,已核验 2026-06-01。 systemd.service 手册,服务单元行为与闲时退出,已核验 2026-09-05。

二月 1, 2028 · 1 分钟 · XB Field Notes
清理规则删除符合条件的目录项,同时保留近期文件和排除的数据。

systemd-tmpfiles 执行创建与年龄策略

systemd-tmpfiles 用配置文件描述文件系统对象与维护规则。一条规则可以按指定模式创建目录,年龄规则则可以让旧条目符合清理条件,但只有命令实际执行对应操作,效果才会发生。这是一种有用的生命周期策略,不是连续监视器,也不是硬性存储配额。目录已经声明、清理命令曾经运行和磁盘空间始终有界,是三种不能互相替代的事实。 坏掉的样子 年龄不只是“创建文件多久了”,也不只是“多久没有修改内容”。文档中的默认规则考虑多个时间戳,包括访问与修改时间、文件状态变化时间,以及可获得的创建时间。某个参与判断的时间戳较新,就可能保留一个看起来 mtime 很老的条目。因此,为了测试而改写时间戳,也可能以意外方式改变结果;不能只观察其中一个字段就断定工具应该删除文件。 --create 和 --clean 执行不同操作。创建配置中的目录,不证明里面的旧内容已经清除。下面使用的 d 规则保留目录本身,同时让符合年龄条件的内容参与清理;其他类型有不同删除语义。不能因为都叫“清理”,就随意换成递归移除规则,否则可能把按条件删除变成完全不同的破坏性操作。 年龄清理中的 x 排除规则可以保留匹配目录及其内容,X 则只排除目录本身,没有相同的递归保护。它们也不会覆盖显式 r 或 R 删除规则。判断一次维护会删什么,必须同时阅读最终生效配置与执行操作,而不是只找到某条看起来写着“忽略”的记录,就认定其他规则都不可能影响该路径。 快速验证 在装有 systemd-tmpfiles 的一次性 Linux 环境中,以管理员身份运行下面的 Python 脚本。实测环境为 Debian 12、systemd 252.39。脚本传入一份明确配置和临时 --root,所有创建对象与删除目标都属于本次实验,不会使用当前系统平时执行的 tmpfiles 配置: from pathlib import Path from tempfile import TemporaryDirectory import os,stat,subprocess,time with TemporaryDirectory(prefix='xbcatm-tmpfiles-') as directory: root=Path(directory);config=root/'policy.conf' definition='d /work 0750 - - 1s -\nx /work/keep - - - - -\n' config.write_text(definition) def apply(action): result=subprocess.run(['systemd-tmpfiles','--root='+str(root),action,str(config)],capture_output=True,text=True,timeout=10) assert result.returncode==0,result.stderr apply('--create');work=root/'work' assert stat.S_IMODE(work.stat().st_mode)==0o750 old=work/'old';old.write_text('fixture');past=time.time()-60;os.utime(old,(past,past)) apply('--clean');assert old.exists() print('old atime and mtime alone do not satisfy the default age rule') config.write_text(definition.replace('1s','m:1s'));apply('--clean') assert not old.exists() print('explicit mtime-only age removes the same recently created file') config.write_text(definition);old.write_text('aged fixture') keep=work/'keep';keep.mkdir();(keep/'preserved').write_text('excluded fixture') outside=root/'outside';outside.write_text('outside cleanup tree') link=work/'link';link.symlink_to(outside) with old.open('rb') as retained: time.sleep(1.2) fresh=work/'fresh';fresh.write_text('recent fixture') apply('--clean') assert not old.exists() and not link.is_symlink() assert retained.read()==b'aged fixture' print('unlinked file remains readable through its already open descriptor') assert fresh.read_text()=='recent fixture' assert (keep/'preserved').read_text()=='excluded fixture' assert outside.read_text()=='outside cleanup tree' assert work.is_dir() print('aged entries removed; recent file and recursively excluded subtree remain') print('symlink removed without following it; target outside the cleaned tree survives') print('all creation and cleanup occurred in a temporary alternate root') 创建操作在临时根目录内生成模式为 0750 的 /work。随后创建一个文件,把访问与修改时间故意调到过去,默认清理仍然保留它,因为其他相关时间戳较新。仅仅回拨 mtime 和 atime,并不足以让这个文件满足完整默认年龄规则。这一对照可以解释为什么只用 touch 修改时间的测试,有时得不到想象中的删除结果。 ...

十二月 10, 2027 · 2 分钟 · XB Field Notes
一次激活按顺序执行命令,并记录完成或失败。

systemd oneshot 完成的是一次激活

Type=oneshot 服务表达一段有限工作,启动命令成功结束后,本次启动才算完成。因此,它适合初始化步骤,以及依赖方需要等待任务结束的定时工作。oneshot 描述的是一次激活的进程模型,不会建立永久记录来阻止任务在重启、再次激活或机器重新开机后重跑。名字中的“一次”,不能直接推导为业务意义上的“永远只做一次”。 坏掉的样子 成功的 oneshot 通常在命令完成后变为非活动状态,这是任务已经结束的正常表现,不是守护进程崩溃的证据。设置 RemainAfterExit=yes 后,管理器会在命令退出后仍把单元记为活动。因此,active (exited) 可以完全没有主进程;如果监控假定任何活动服务都必须有一个存活 PID,就会错误判断这种单元类型。 oneshot 允许多条 ExecStart,并按顺序执行。采用普通成功规则时,某条命令失败会阻止后续启动命令运行,并让本次激活失败。但前面的命令可能已经修改文件或数据库,systemd 不会回滚这些副作用。重新尝试整个激活,就可能重复之前已经成功的部分,不能把管理器的失败状态误当作业务事务已经全部撤销。 RemainAfterExit 保存的是管理器状态,不是持久的恰好一次执行保证。再次启动已经活动的单元不会重跑,重启却会。定时器指向一个仍然活动的 oneshot 时,也不会把每个到达的时间点都变成另一次执行。只有它确实代表一个持续成立的状态时,才适合保留活动标记;需要反复触发的定时工作,应在各次执行之间回到非活动状态。 快速验证 在由 systemd 管理的临时 Linux 环境中,以管理员身份运行下列 Python 脚本。它创建一个运行时单元和临时步骤程序。实测环境为 Debian 12、systemd 252.39,执行前确认示例单元文件名尚未使用。每个步骤先记录自身名字,第二步随后故意失败: from pathlib import Path from tempfile import TemporaryDirectory import subprocess unit='xbcatm-oneshot-lab.service';path=Path('/run/systemd/system')/unit assert not path.exists() def ctl(*args,success=True): result=subprocess.run(['systemctl',*args],text=True,capture_output=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') with TemporaryDirectory(prefix='xbcatm-oneshot-',dir='/run') as directory: root=Path(directory);worker=root/'step.py';calls=root/'calls' worker.write_text("""from pathlib import Path import sys root=Path(__file__).parent with (root/'calls').open('a') as output: output.write(sys.argv[1]+'\\n') if sys.argv[1]=='second' and not (root/'healthy').exists(): sys.exit(7) """) definition=f'''[Service] Type=oneshot RemainAfterExit=yes TimeoutStartSec=3s ExecStart=/usr/bin/python3 {worker} first ExecStart=/usr/bin/python3 {worker} second ExecStart=/usr/bin/python3 {worker} third ''' path.write_text(definition) try: subprocess.run(['systemd-analyze','verify',str(path)],check=True) ctl('daemon-reload');ctl('start',unit,success=False) assert calls.read_text().splitlines()==['first','second'] assert value('Result')=='exit-code' and value('ExecMainStatus')=='7' print('second command fails; third command does not run') (root/'healthy').touch();ctl('reset-failed',unit);ctl('start',unit) assert calls.read_text().splitlines()==['first','second','first','second','third'] assert (value('ActiveState'),value('SubState'),value('MainPID'))==('active','exited','0') before=calls.read_text();ctl('start',unit);assert calls.read_text()==before print('active/exited with PID 0; repeated start does not rerun') ctl('restart',unit) assert calls.read_text()==before+'first\nsecond\nthird\n' print('restart executes all commands again') ctl('stop',unit);path.write_text(definition.replace('RemainAfterExit=yes','RemainAfterExit=no')) ctl('daemon-reload');before=calls.read_text() for _ in range(2): ctl('start',unit) assert value('ActiveState')=='inactive' and value('Result')=='success' assert calls.read_text()==before+'first\nsecond\nthird\n'*2 print('without RemainAfterExit: two starts, two successful executions') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) path.unlink();ctl('daemon-reload') 第一次激活只留下 first 和 second 两项记录,启动命令返回失败,退出状态为七;第三条命令没有运行。此前的记录仍然存在,让我们直接看见部分副作用,而不是假设激活失败意味着什么都没做过。这对于会修改真实数据的任务尤其关键:管理器状态并不能描述全部业务影响。 ...

十一月 28, 2027 · 1 分钟 · XB Field Notes
管理器持续持有监听套接字,兼容的工作进程可以按需要启动与结束。

Socket 激活把监听器交给兼容的服务

Socket 激活让 systemd 在应用进程启动之前先打开监听端点。端点上的活动随后可以触发兼容服务,并把管理器已经持有的套接字描述符传给它。这里真正分开的,是监听器所有权与应用生命周期。端点已经开放,还不证明应用完成初始化、接受了请求或生成了正确响应;传输层能连接与业务层能工作,需要分别验证。 坏掉的样子 应用必须理解激活协议。如果守护进程无条件再次绑定同一个端点,就可能因为管理器已经占用而失败。激活机制把绑定责任移到 socket 单元,并不会消除地址冲突、接口缺失、文件系统权限错误,或其他导致首次绑定失败的条件。因此不能把“端口先开好”写成所有启动竞态已经自动消失,真正的监听建立仍然可能失败。 使用 Accept=no 时,服务接收监听套接字并自行接受连接;Accept=yes 则为合适的流套接字支持按连接启动服务实例的模式。它们对应不同的应用契约,不能互换。任何一种模式都不意味着任意守护进程会自动判断空闲、完成工作后退出,或者只在安全时重新启动。是否退出,以及退出时哪些请求已经完成,仍然由应用行为决定。 排队连接也有边界。客户端可以在应用初始化完成之前建立传输状态,随后因为等不到响应而超时。Socket 激活不会让数据库依赖自动就绪,也不会提供无限请求队列。真实工作负载仍需要合适的服务依赖、就绪逻辑与客户端超时;先拥有监听器只是交付链路的一部分,不等于依赖链已经完整兑现。 快速验证 在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录环境为 Debian 12、systemd 252.39。脚本在临时目录中使用 Unix 流套接字,创建唯一命名的 socket 与 service,不暴露公共 TCP 端口。工作程序有意只处理一条以换行结尾的消息,然后退出: from pathlib import Path from tempfile import TemporaryDirectory import json,socket,subprocess,time,uuid name='xbcatm-socket-'+uuid.uuid4().hex[:8];service=name+'.service';unit=name+'.socket' paths={n:Path('/run/systemd/system')/n for n in (service,unit)} assert all(not p.exists() for p in paths.values()) def run(*args): result=subprocess.run(args,capture_output=True,text=True,timeout=10) assert result.returncode==0,(args,result.stderr) return result.stdout.strip() def value(key):return run('systemctl','show',service,'-p',key,'--value') def wait_inactive(): deadline=time.monotonic()+5 while value('ActiveState')!='inactive': assert time.monotonic()<deadline,'service did not exit';time.sleep(0.02) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);endpoint=root/'listener.sock';worker=root/'worker.py' worker.write_text(r'''from pathlib import Path import errno,json,os,socket root=Path(__file__).parent assert int(os.environ['LISTEN_PID'])==os.getpid() assert int(os.environ['LISTEN_FDS'])==1 with socket.socket(fileno=3) as listener: assert listener.getsockopt(socket.SOL_SOCKET,socket.SO_ACCEPTCONN)==1 with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as duplicate: try:duplicate.bind(str(root/'listener.sock')) except OSError as error:assert error.errno==errno.EADDRINUSE else:raise AssertionError('unexpected duplicate bind success') connection,_=listener.accept() with connection,connection.makefile('rb') as incoming: message=incoming.readline(256);assert message.endswith(b'\n') connection.sendall(b'ack '+message) (root/(str(os.getpid())+'.json')).write_text(json.dumps({'pid':os.getpid(),'fd':3,'message':message.decode().strip()})) ''') paths[unit].write_text(f'[Socket]\nListenStream={endpoint}\nAccept=no\nSocketMode=0600\nRemoveOnStop=yes\n') paths[service].write_text(f'[Service]\nType=exec\nExecStart=/usr/bin/python3 {worker}\n') try: run('systemd-analyze','verify',*[str(p) for p in paths.values()]);run('systemctl','daemon-reload') run('systemctl','start',unit) assert endpoint.is_socket() and value('ActiveState')=='inactive' and value('MainPID')=='0' print('socket listening while service has no process') for message in ('first','second'): with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as client: client.settimeout(5);client.connect(str(endpoint));client.sendall((message+'\n').encode()) with client.makefile('rb') as response:assert response.readline()==('ack '+message+'\n').encode() wait_inactive();assert endpoint.is_socket() and value('Result')=='success' records=[json.loads(p.read_text()) for p in root.glob('*.json')] assert len(records)==2 and len({row['pid'] for row in records})==2 assert {row['message'] for row in records}=={'first','second'} assert all(row['fd']==3 for row in records) print('two requests: two deliberate one-request workers; each uses inherited listening fd 3') print('rebinding the occupied socket fails; manager retains listener between workers') run('systemctl','stop',unit);assert not endpoint.exists() print('stopping socket removes its endpoint with RemoveOnStop=yes') finally: run('systemctl','stop',unit,service) for name,path in paths.items(): if run('systemctl','show',name,'-p','ActiveState','--value')=='failed':run('systemctl','reset-failed',name) path.unlink() run('systemctl','daemon-reload') 只启动 socket 单元后,端点已经存在,对应服务却仍为非活动,主进程编号为零。第一个客户端连接随后触发工作程序执行,客户端发送有完整边界的消息,并检查应用回复,不会仅凭 connect 成功就认定服务可用。流式连接并不自动划分应用消息,所以样本明确使用换行作为这一小型协议的结束标记。 ...

十一月 8, 2027 · 2 分钟 · XB Field Notes
重启决策取决于退出分类、等待时间与启动速率限制。

systemd 重启策略要明确失败与限流规则

重启策略决定当前进程结束后,服务管理器是否应该再启动一个进程。它可以帮助恢复短暂故障,但修不好缺失配置或必然重复的程序错误。一个有用的契约需要分别回答三个问题:哪些结束方式应该重试、重试前等多久、一个时间窗口内允许启动多少次。这些设置各管一件事,不能把它们笼统理解为“失败后会自己恢复”。 坏掉的样子 Restart=on-failure 不代表任何信号结束都会重启。对于 oneshot 以外的服务类型,通常的 SIGHUP、SIGINT、SIGTERM 和 SIGPIPE 终止,默认被视为干净结束;非零退出码,以及 SIGKILL 这样的异常信号,才符合默认失败规则。SuccessExitStatus 和专门的重启例外指令还能改变分类,因此要检查最终生效的单元,不能只看其中一行策略名字。 on-abnormal 也不是 on-failure 的“更严格同义词”:它排除了普通的非零进程退出码,保留异常信号以及部分超时或看门狗故障。选择依据应是程序怎样报告可恢复失败与主动结束。如果进程始终存活,却已经不再提供有用工作,还需要单独配置发现这种状态的机制;基于退出的重启逻辑不会自动推断业务是否健康。 RestartSec 提供下一次重启前的等待时间。在下面的 systemd 252 实验里,它是固定延迟,不是越来越长的指数退避。StartLimitIntervalSec 和 StartLimitBurst 限制窗口内的启动次数,手动启动也算在内;它们既不是整个生命周期的重试预算,也不会在超限后自动预约下一窗口边界再尝试一次。限流与恢复触发应分别理解。 快速验证 在一次性的 Linux systemd 环境中,以管理员身份运行这个 Python 脚本。它创建带有唯一名字的运行时单元和临时工作程序,完成后删除。实测环境为 Debian 12、systemd 252.39。工作程序起初每次都以状态七退出,同时记录每次真实调用: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,time,uuid unit='xbcatm-restart-lab-'+uuid.uuid4().hex[:8]+'.service' path=Path('/run/systemd/system')/unit assert not path.exists() def ctl(*args): result=subprocess.run(['systemctl',*args],text=True,capture_output=True,timeout=10) assert result.returncode==0,(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-restart-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py';calls=root/'calls';healthy=root/'healthy' worker.write_text("""from pathlib import Path import sys,time root=Path(__file__).parent with (root/'calls').open('a') as output: output.write('started\\n') if not (root/'healthy').exists(): sys.exit(7) time.sleep(60) """) path.write_text(f'''[Unit] StartLimitIntervalSec=10s StartLimitBurst=3 [Service] Type=exec ExecStart=/usr/bin/python3 {worker} Restart=on-failure RestartSec=100ms ''') try: ctl('daemon-reload');ctl('start',unit) wait_for(lambda: value('ActiveState')=='failed') assert calls.read_text().splitlines()==['started']*3 assert value('Result')=='exit-code' subprocess.run(['journalctl','--sync'],check=True) logs=subprocess.run(['journalctl','-u',unit,'--no-pager','-o','cat'],capture_output=True,text=True,check=True).stdout assert 'Start request repeated too quickly' in logs print('three starts; failed/exit-code with start-rate-limit journal message') healthy.touch();ctl('reset-failed',unit);ctl('start',unit) wait_for(lambda: len(calls.read_text().splitlines())==4) first=value('MainPID');assert first!='0' ctl('kill','--kill-who=main','--signal=TERM',unit) wait_for(lambda: value('ActiveState')=='inactive') assert value('NRestarts')=='0' print('on-failure treats ordinary SIGTERM as clean; no restart') ctl('start',unit);first=value('MainPID') ctl('kill','--kill-who=main','--signal=KILL',unit) wait_for(lambda: value('MainPID') not in ('0',first)) assert value('NRestarts')=='1' print('SIGKILL triggers one restart') ctl('stop',unit);time.sleep(0.2) assert value('ActiveState')=='inactive' print('explicit stop remains stopped') finally: ctl('stop',unit) if value('ActiveState')=='failed':ctl('reset-failed',unit) path.unlink();ctl('daemon-reload') 第一阶段允许十秒内启动三次,自动重启间隔设得很短且固定。程序自己写出的文件恰好包含三项记录,所以这里统计的是实际进程启动,而不是先猜某个管理器计数器的含义,再把那个数字当作执行证据。之后单元留在失败状态,日志明确说明启动请求过于频繁,新的启动被速率规则挡住。 ...

十一月 7, 2027 · 1 分钟 · XB Field Notes
当前写入的 journal 文件与归档文件适用不同的删除规则。

systemd journal 的保留范围需要明确约定

systemd journal 把消息与结构化元数据一起保存在日志文件中,因此可以查询服务输出,不必先为每种应用文本设计一套解析器。但让这些消息保留足够长时间,是另一项需要明确的要求。存储位置、空间策略、轮转方式与转发路径,决定了事故发生一段时间后还有哪些证据可查。查询方便,不等于历史永远完整,也不等于数据已经离开这台机器安全保存。 坏掉的样子 默认命名空间通常使用 Storage=auto:存在 /var/log/journal 时选择持久存储,否则使用运行时存储。Storage=volatile 把文件放在 /run/log/journal 下,并不是简单借用内核的文本环形缓冲区。这些运行时文件通常会在整台机器重新启动后消失,但重启 journald 服务是不同的事件,并不必然清空文件。排查“重启后日志去哪了”之前,必须明确重启的是哪个对象。 Storage=persistent 优先使用 /var/log/journal,早期启动阶段或持久位置不可写时仍可能使用运行时存储。满足文档规定条件时,flush 操作可以转移运行时数据。因此应检查真实文件和最终生效配置,不能只因为某份配置片段里出现了 persistent,就推定所有消息已经写在预期磁盘上。路径、挂载状态与执行时点都需要对应到实际记录。 journald 本身有默认空间限制和轮转行为,不会仅仅因为管理员没有写一个轮转任务就无限增长。不过,SystemMaxUse 也不是保证文件系统永不写满的精确硬配额。运行时存储有另一组设置,可用空间限制同时起作用,其他程序也会占用磁盘,而清理时只能删除已经归档的 journal 文件。把这些条件压缩成“设置一个上限就绝对安全”,会掩盖真正的磁盘压力来源。 快速验证 在由 systemd 管理的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。记录环境为 Debian 12、systemd 252.39。脚本创建唯一日志命名空间,显式配置持久存储,通过临时服务写入两条无害消息,最后只删除该命名空间的数据。实验中的 vacuum 会有意删除自己刚写入的旧消息: from pathlib import Path import json,shutil,subprocess,uuid name='xbcatm-journal-'+uuid.uuid4().hex[:8] config=Path('/etc/systemd')/('journald@'+name+'.conf') directory=Path('/var/log/journal')/(Path('/etc/machine-id').read_text().strip()+'.'+name) assert not config.exists() and not directory.exists() def run(*args): result=subprocess.run(args,text=True,capture_output=True,timeout=15) assert result.returncode==0,(args,result.stderr) return result.stdout def journal(*args):return run('journalctl','--namespace='+name,*args) def emit(message): run('systemd-run','--quiet','--wait','--collect','--unit='+name+'-'+message, '--property=LogNamespace='+name,'--property=StandardOutput=journal', '--property=SyslogIdentifier='+name,'/usr/bin/printf',message+'\n') journal('--sync') def messages(): rows=journal('--no-pager','-o','json','SYSLOG_IDENTIFIER='+name) return [json.loads(line)['MESSAGE'] for line in rows.splitlines()] config.write_text('[Journal]\nStorage=persistent\nSystemMaxUse=16M\nRuntimeMaxUse=8M\n') try: emit('old');journal('--flush');journal('--sync') assert messages()==['old'] and list(directory.glob('*.journal')) run('systemctl','restart','systemd-journald@'+name+'.service') assert messages()==['old'] print('persistent namespace: message survives journald service restart') journal('--vacuum-size=1') assert messages()==['old'] print('vacuum cannot remove the active journal file to satisfy a one-byte target') journal('--rotate');emit('new') assert messages()==['old','new'] assert list(directory.glob('*@*.journal')) journal('--vacuum-size=1') assert messages()==['new'] and not list(directory.glob('*@*.journal')) print('after rotation: archived old message removed; active new message remains') print('persistent files observed under /var/log/journal; no reboot durability claim') finally: run('systemctl','stop','systemd-journald@'+name+'.service', 'systemd-journald@'+name+'.socket','systemd-journald-varlink@'+name+'.socket') config.unlink(missing_ok=True) for path in (directory,Path('/run/log/journal')/directory.name,Path('/run/systemd')/('journal.'+name)): if path.exists():shutil.rmtree(path) 第一条消息位于该命名空间的 /var/log/journal 目录中,重启对应 journald 服务后仍然能够查到。这验证了选中的存储位置,以及消息跨越日志守护进程重启仍然存在;它没有模拟突然断电,也没有证明存储设备的持久性,更没有执行整台机器重启。说明验证范围,可以避免把一个真实但有限的结果扩大成并未做过的耐久性测试。 ...

十月 26, 2027 · 1 分钟 · XB Field Notes
通过 journal 字段把同一服务的两次运行缩小为精确结果集。

按明确的字段与优先级规则查询 journal

有效的 journal 查询应该选出能够回答具体事故问题的记录。单元身份、运行实例、时间、优先级和应用字段提供了不同维度;正确组合它们,比在整台机器的历史里找到一个熟悉词语更重要。journald 可以附加进程元数据,但不会自动收集应用写出的每个文件,也不会自动理解每条消息的业务含义。查询条件和日志实际采集路径必须相互对应。 坏掉的样子 字段匹配有明确的布尔组合规则:不同字段之间是 AND,同一字段的多个取值之间是 OR,单独一个 + 则把两组条件按 OR 连接。因此,再添加一个字段值,有时会扩大结果集,而不是进一步缩小。应把查询看成一个明确的条件表达式,先用已知记录验证它,再放进事故脚本,避免因为“参数更多了”就误认为结果必然更精确。 -p err 选择错误及更严重的级别,并不是只选择数字三。如果真正需要精确的三级记录,应使用 PRIORITY=3 字段匹配。应用在文本中写了“error”,本身也不等于设置了错误优先级;标准输出的采集使用配置好的优先级规则,除非应用通过受支持的表示方式提供级别。消息文字、日志字段和筛选阈值不能互相混同。 命令成功但没有记录,不证明服务从未运行。所选启动周期、时间窗口、命名空间、读取权限、保留策略或输出路由,都可能解释空结果。反过来,查询一个单元的全部历史,又可能找到以前的成功启动,然后错误归因到当前进程。应区分服务长期身份与某一次具体运行,不能只因为单元名称相同就把证据拼接成一次执行。 快速验证 在 PID 1 为 systemd 的一次性 Linux 系统中,以管理员身份运行下面的 Python 脚本。实测环境是 Debian 12、systemd 252.39。脚本安装一个唯一命名的临时服务,并激活两次。每个工作进程向原生日志套接字发送四条较短的结构化消息,分别包含两个应用组件和几个明确的优先级: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid name='xbcatm-query-'+uuid.uuid4().hex[:8];unit=name+'.service' path=Path('/run/systemd/system')/unit assert not path.exists() 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 query(*matches): output=run('journalctl','--no-pager','-o','json','-u',unit,*matches) return [json.loads(line) for line in output.splitlines() if line] def labels(rows):return sorted(row['MESSAGE'] for row in rows) with TemporaryDirectory(prefix=name+'-',dir='/run') as directory: root=Path(directory);worker=root/'worker.py' worker.write_text(r'''from pathlib import Path import socket phase=Path(__file__).with_name('phase').read_text() with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as log: log.connect('/run/systemd/journal/socket') for component,priority in [('api',2),('api',3),('api',6),('db',3)]: fields=[f'MESSAGE={phase}-{component}-{priority}',f'PRIORITY={priority}', f'LAB_RUN={phase}',f'LAB_COMPONENT={component}', '_SYSTEMD_UNIT=forged.service'] log.sendall(('\n'.join(fields)+'\n').encode()) ''') path.write_text(f'[Service]\nType=oneshot\nExecStart=/usr/bin/python3 {worker}\n') try: run('systemd-analyze','verify',str(path));run('systemctl','daemon-reload') for phase in ('first','second'): (root/'phase').write_text(phase);run('systemctl','start',unit) run('journalctl','--sync') first=query('LAB_RUN=first') assert len(first)==4 and all(row['_SYSTEMD_UNIT']==unit for row in first) second=query('LAB_RUN=second');assert len(second)==4 invocation=first[0]['_SYSTEMD_INVOCATION_ID'] assert {row['_SYSTEMD_INVOCATION_ID'] for row in first}=={invocation} assert {row['_SYSTEMD_INVOCATION_ID'] for row in second}!={invocation} assert labels(query('LAB_RUN=first','LAB_COMPONENT=api','-p','err'))==['first-api-2','first-api-3'] print('different fields AND; -p err includes critical priority 2 and error priority 3') assert labels(query('LAB_RUN=first','LAB_COMPONENT=api','PRIORITY=3'))==['first-api-3'] assert labels(query('LAB_RUN=first','LAB_COMPONENT=api','LAB_COMPONENT=db','PRIORITY=3'))==['first-api-3','first-db-3'] print('exact PRIORITY=3 differs from threshold; repeated component fields OR') union=query('LAB_RUN=first','LAB_COMPONENT=db','+','LAB_RUN=second','LAB_COMPONENT=db') assert labels(union)==['first-db-3','second-db-3'] assert labels(query('_SYSTEMD_INVOCATION_ID='+invocation,'LAB_COMPONENT=db'))==['first-db-3'] print('explicit + joins match groups; invocation filter separates repeated starts') assert query('LAB_RUN=absent')==[] print('empty query still succeeds; underscore unit field comes from journal metadata') finally: run('systemctl','stop',unit) if run('systemctl','show',unit,'-p','ActiveState','--value')=='failed':run('systemctl','reset-failed',unit) path.unlink();run('systemctl','daemon-reload') 第一次激活生成 API 严重消息、API 错误消息、API 普通信息和数据库错误消息;第二次生成相同类别,但带另一个应用运行标签。按第一次运行、API 组件和 -p err 过滤,会返回两条记录,优先级分别为二和三。改成精确匹配三级,只剩 API 错误。这组对照直接展示阈值与精确值的区别,而不是仅解释命令名称。 ...

七月 30, 2027 · 2 分钟 · XB Field Notes