三个相连方块,依次标有 Observe、Decide 和 Act。

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
关于inotify 通知的标题卡,配有箭头连接的通用 Observe、Decide、Act 面板。

inotify 报告变化,但不是完整历史记录

inotify 通过一个带注册监视项和可读事件队列的实例,投递 Linux 文件系统通知。每条记录包含监视编号、事件掩码,也可能包含名字和移动 cookie。这能减少对未变化路径的反复检查,但事件流不是无限审计日志。正确消费者应把通知理解为相关状态可能需要重新检查的证据,而不能默认它保存了文件发生过的每一次操作。 坏掉的样子 监视目录不会递归监视后代。它报告直接目录项上的相关活动,因此需要观察更深变化时,新子目录也需要独立监视。目录树监视器必须处理初次枚举和之后加入的目录。假定只注册根目录就覆盖整棵树,会悄悄漏掉更深层变化;能收到子目录被创建,并不代表能收到它内部之后的全部操作。 文件监视跟随具体对象,不会永久绑定路径。编辑器可以先写完并关闭临时文件,再用 rename 替换目标。父目录会报告替换,旧文件监视却不会转移到新 inode。如果消费者等待目标名字之后再出现 close-write,可能永远等不到,因为替代文件在发布前已经关闭。不能把一种编辑器保存顺序当成所有更新方式的固定流程。 连续产生且尚未读取的相同事件可以合并,队列满后会丢弃事件并报告 IN_Q_OVERFLOW。因此,事件数量不是可靠写入计数。溢出后,消费者无法仅靠剩余记录重建全部缺失操作,必须重新核对当前监视状态,不能继续假定根据事件维护的模型完整。历史丢失与当前状态仍可读取,是需要分别理解的两件事。 快速验证 把脚本保存为 inotify-events.py,在一次性 Linux 环境中运行 python3 inotify-events.py。此前记录使用 Linux 6.12.76 和 ext4;本轮在 Linux 6.18.44、Python 3.12.14 环境重跑了两种语言的样本。样本创建 ASCII 名称的临时条目,并有意填满自己的队列。配置的队列上限必须不超过三万二千七百六十八;脚本只读取上限,不修改任何 sysctl: from pathlib import Path from tempfile import TemporaryDirectory import ctypes,os,struct MODIFY=0x2;ATTRIB=0x4;CLOSE_WRITE=0x8;MOVED_FROM=0x40;MOVED_TO=0x80 CREATE=0x100;DELETE=0x200;DELETE_SELF=0x400;MOVE_SELF=0x800;OVERFLOW=0x4000;IGNORED=0x8000 libc=ctypes.CDLL(None,use_errno=True) libc.inotify_init1.argtypes=[ctypes.c_int];libc.inotify_init1.restype=ctypes.c_int libc.inotify_add_watch.argtypes=[ctypes.c_int,ctypes.c_char_p,ctypes.c_uint32];libc.inotify_add_watch.restype=ctypes.c_int header=struct.Struct('iIII') with TemporaryDirectory(prefix='xbcatm-inotify-') as directory: root=Path(directory);fd=libc.inotify_init1(os.O_NONBLOCK|os.O_CLOEXEC);assert fd>=0,os.strerror(ctypes.get_errno()) def watch(path,mask): wd=libc.inotify_add_watch(fd,os.fsencode(path),mask);assert wd>=0,os.strerror(ctypes.get_errno());return wd def drain(): events=[] while True: try:data=os.read(fd,65536) except BlockingIOError:return events offset=0 while offset<len(data): wd,mask,cookie,length=header.unpack_from(data,offset);offset+=header.size name=data[offset:offset+length].split(b'\0',1)[0].decode();offset+=length events.append((wd,mask,cookie,name)) try: parent=watch(root,MODIFY|CLOSE_WRITE|CREATE|DELETE|MOVED_FROM|MOVED_TO) sub=root/'sub';sub.mkdir();drain();(sub/'unwatched').touch();assert drain()==[] nested=watch(sub,CREATE);(sub/'watched').touch();assert any(wd==nested and mask&CREATE and name=='watched' for wd,mask,_,name in drain()) print('a directory watch is not recursive; adding a child-directory watch observes its new entries') counter=root/'counter';stream=os.open(counter,os.O_CREAT|os.O_WRONLY,0o600);drain() try: for _ in range(3):os.write(stream,b'x') finally:os.close(stream) events=drain();assert sum(wd==parent and bool(mask&MODIFY) and name=='counter' for wd,mask,_,name in events)==1 moved=root/'moved';counter.rename(moved);events=drain() before=next(e for e in events if e[1]&MOVED_FROM and e[3]=='counter');after=next(e for e in events if e[1]&MOVED_TO and e[3]=='moved') assert before[2]==after[2]!=0 print('three unread identical modifications coalesce; rename FROM/TO events share a nonzero cookie') target=root/'target';target.write_bytes(b'old');file_watch=watch(target,MODIFY|ATTRIB|DELETE_SELF|MOVE_SELF);drain() replacement=root/'replacement';replacement.write_bytes(b'new');drain();replacement.replace(target);events=drain() assert any(wd==file_watch and mask&IGNORED for wd,mask,_,_ in events) assert any(wd==parent and mask&MOVED_TO and name=='target' for wd,mask,_,name in events) assert not any(wd==parent and mask&CLOSE_WRITE and name=='target' for wd,mask,_,name in events) target.write_bytes(b'newer');events=drain() assert any(wd==parent and mask&MODIFY and name=='target' for wd,mask,_,name in events) assert all(wd!=file_watch for wd,_,_,_ in events) print('replacement invalidates the old file watch; the parent-directory watch continues to observe the pathname') burst=root/'burst';burst.mkdir();drain();watch(burst,CREATE) limit=int(Path('/proc/sys/fs/inotify/max_queued_events').read_text());assert 0<limit<=32768,'use a disposable environment with a bounded queue limit' for number in range(limit+8):(burst/str(number)).touch() events=drain();assert any(wd==-1 and mask&OVERFLOW for wd,mask,_,_ in events) current=len(list(burst.iterdir()));assert current==limit+8 print('actual queue overflow:',len(events),'events retained for',current,'creates; rescan sees the current entries') finally:os.close(fd) 第一组监视父目录,创建子目录,并读走父级事件。随后在子目录内部创建文件,不会向父监视项产生事件;显式增加子目录监视后,下一次创建才被看到。这在同一个 inotify 实例中区分直接目录项覆盖和递归监视,避免只检查注册调用成功就以为整棵树都已进入观察范围。 ...

十月 13, 2027 · 2 分钟 · XB Field Notes
浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

硬链接共享 inode,替换名字后关系会改变

硬链接为已有文件增加另一个目录项。两个名字都指向同一文件系统里的同一个 inode,其中没有哪个天然具有“原件”地位。内容、所有者、权限和链接数属于这个共享对象,文件名只是到达它的入口。因此,需要把目录项的变化与文件对象本身的变化分开理解,才能判断修改、删除和替换到底影响了哪些访问路径。 坏掉的样子 把硬链接当成独立备份,会产生错误的恢复边界。通过任意名字写入或截断共享 inode,另一名字读到的内容也会改变;修改模式位,同样改变的是一个对象的权限。第二个路径可以在第一个路径被删除后继续存在,却不能抵御针对共享文件的普通原地修改,也不会自动保留先前历史版本。 许多编辑器和部署工具采用另一种更新方式:先创建替代文件,再通过 rename 覆盖某个路径。该目录项此时指向新 inode,其他硬链接仍然到达旧对象。两个名字是否一起显示新内容,取决于实际更新方法。因此不能仅凭它们昨天建立过硬链接,就断言今天仍共享身份;应检查当前对象以及写入工具的真实行为。 硬链接还受文件系统挂载边界约束。即使两个目录在统一路径树里看起来相邻,link 也不能随意把不同挂载中的对象连接起来。Linux 还禁止普通目录硬链接。这些限制意味着,硬链接不能作为跨目录层次、跨挂载符号链接的通用替代品;使用前必须先确认目标对象和边界,而不是只看路径字符串。 快速验证 把脚本保存为 hardlinks.py,在有权创建临时目录的 Linux 环境运行 python3 hardlinks.py。它要求 /tmp 与 /run 可写且属于不同文件系统;需要时可将 CROSS_ROOT 设为另一文件系统上的可写目录。实验不要求 root,也不创建挂载,并会明确检查跨文件系统前提。实测使用两个独立 tmpfs 挂载、Linux 6.12.76 和 Python 3.11.2,所有新建条目都位于临时目录中: from pathlib import Path from tempfile import TemporaryDirectory import errno,os with TemporaryDirectory(prefix='xbcatm-hardlinks-',dir='/tmp') as directory, TemporaryDirectory(prefix='xbcatm-crosslink-',dir=os.environ.get('CROSS_ROOT','/run')) as other: root=Path(directory);source=root/'source';alias=root/'alias' source.write_bytes(b'original');os.link(source,alias) before=source.stat();linked=alias.stat() assert (before.st_dev,before.st_ino)==(linked.st_dev,linked.st_ino) assert before.st_nlink==linked.st_nlink==2 and before.st_size==linked.st_size==8 alias.write_bytes(b'changed') assert source.read_bytes()==b'changed' print('two names share device/inode, size, and link count; in-place write changes both views') replacement=root/'replacement';replacement.write_bytes(b'new file');replacement.replace(source) assert source.stat().st_ino!=alias.stat().st_ino assert source.read_bytes()==b'new file' and alias.read_bytes()==b'changed' assert source.stat().st_nlink==alias.stat().st_nlink==1 print('atomic replacement changes one name to a new inode; the other name retains the old file') with alias.open('rb') as opened: alias.unlink() assert os.fstat(opened.fileno()).st_nlink==0 and opened.read()==b'changed' print('last unlink removes the final name but an existing open descriptor still reads the old data') assert root.stat().st_dev!=Path(other).stat().st_dev,'exercise requires two filesystem mounts' try:os.link(source,Path(other)/'cross-filesystem') except OSError as error:assert error.errno==errno.EXDEV,error else:raise AssertionError('cross-filesystem hard link unexpectedly succeeded') child=root/'directory';child.mkdir() try:os.link(child,root/'directory-link') except OSError as error:assert error.errno==errno.EPERM,error else:raise AssertionError('directory hard link unexpectedly succeeded') print('cross-filesystem link fails with EXDEV; directory hard link fails with EPERM on Linux') 样本先创建 source,写入八字节内容,再通过 os.link 添加 alias。两个名字报告相同的设备与 inode 组合、八字节大小,以及值为二的链接数。比较时必须包含设备身份,因为 inode 编号并不跨文件系统全局唯一;仅仅比较内容相同,也无法证明两个入口共享对象,独立副本同样可能有相同字节。 ...

十月 10, 2027 · 2 分钟 · XB Field Notes
两行标题旁边的三个相连方块,依次标注 Observe、Decide 与 Act。

Cron 按日历重复触发任务

Cron 按日历字段匹配时间,并在匹配时启动命令。它不会天然等待同一个命令的上一次执行结束。如果每分钟触发的任务要运行七十秒,下一分钟就可能再启动一份。因此,触发规则、并发规则和成功完成状态必须分别设计,不能因为写好了一个周期表达式,就认为重复工作的所有行为都已经确定。 把字段理解为日历选择 传统用户 crontab 一行依次包含分钟、小时、月内日期、月份、星期,然后是命令。星号表示该字段的全部取值。分钟位置的 */15 是在每个小时内选择相应分钟值,不是从上次任务结束之后再计时十五分钟。步长如果不能均匀划分字段范围,跨过边界时就可能出现不同长度的间隔,不能把它当作连续经过时长。 Debian/Vixie 格式对于两个日期字段有特殊规则:当月内日期和星期都受到限制时,满足其中一个日期条件就可能运行,而分钟、小时和月份限制仍然必须满足。这不是月份与星期之间的“或”关系。安装简短表达式之前,应列出具有代表性的实际日期核对意图,避免以为“每月某日恰好是周五”却写成两个日期条件各自都能触发。 时区、夏令时变化和停机都会影响日历调度,不同实现对跳过或重复时段的处理并不完全相同。Debian 文档为部分小幅时钟调整和固定时间任务定义了特殊行为,从其他调度器复制结论可能不适用。本实验没有修改时钟,也没有覆盖这些切换情况。日历触发同样不等于持久队列,不能自动保证停机期间错过的每次任务都会补跑。 坏掉的样子 重叠任务可能重复产生副作用、争夺资源或相互覆盖文件。如果要求同一时刻只有一个实例,就要明确实现这一政策。非阻塞建议锁可以让后来的实例记录跳过;阻塞锁则可能留下等待进程。两种选择都不自动提供业务效果恰好一次,也不能修复已经执行了一半的操作。是否允许跳过,应来自任务自己的业务要求。 本地文件锁只协调使用同一相关锁机制的参与者,不会自动串行化无关主机上的工作。网络文件系统的锁语义也需要单独确认。锁必须覆盖实际工作生命周期,跳过结果则应能被观察。如果一个实例永久卡住,后来每次触发都静默丢弃,表面上调度器还在工作,但业务可能早已停止推进,因此还要监控最近成功完成距今多久。 Cron 也并非一律没有日志。Debian 默认通过 syslog 记录任务启动,并提供更细的日志选项。命令输出可能在可用邮件链路存在时被投递,但极简容器经常没有这样的链路。应把启动、完成、失败和跳过结果写到明确目的地。启动记录只能证明任务被派发,不能证明真正的应用工作已经成功完成。 快速验证 使用全新的临时 Debian 12 Docker 容器,以 root 运行,确保没有既有 root 任务表或 cron 进程。通过 apt 安装 python3、cron 和 procps,将下面代码保存为 cron-overlap.py,再执行 python3 cron-overlap.py。需要预留约两分钟,因为程序等待的是实际日历触发,没有用模拟调度器替代 cron,也没有加速或调整宿主时钟。 from pathlib import Path from tempfile import TemporaryDirectory import json import os import subprocess import time assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container' assert subprocess.run(['pgrep', '-x', 'cron'], capture_output=True).returncode == 1 assert subprocess.run(['crontab', '-l'], capture_output=True).returncode != 0 with TemporaryDirectory(prefix='cron-overlap-') as directory: root = Path(directory) events_path = root / 'events.jsonl' stop = root / 'stop' worker = root / 'worker.py' worker.write_text('''from pathlib import Path import fcntl,json,os,sys,time root=Path(__file__).parent kind=sys.argv[1] def record(event): data=json.dumps({'event':event,'kind':kind,'pid':os.getpid(),'monotonic':time.monotonic()})+'\\n' fd=os.open(root/'events.jsonl',os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600) try: os.write(fd,data.encode()) finally: os.close(fd) lock=None if kind=='locked': lock=open(root/'job.lock','a') try: fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB) except BlockingIOError: record('skip') raise SystemExit(0) record('start') try: until=time.monotonic()+70 while time.monotonic()<until and not (root/'stop').exists(): time.sleep(0.1) finally: record('end') if lock: lock.close() ''') table = ''.join(f'* * * * * /usr/bin/python3 {worker} {kind} >>{root}/worker.log 2>&1\n' for kind in ('plain', 'locked')) subprocess.run(['crontab', '-'], input=table, text=True, check=True) daemon = subprocess.Popen(['cron', '-f'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def read_events(): if not events_path.exists(): return [] return [json.loads(line) for line in events_path.read_text().splitlines()] try: deadline = time.monotonic() + 140 while time.monotonic() < deadline: assert daemon.poll() is None, 'cron exited' events = read_events() plain = [e for e in events if (e['kind'], e['event']) == ('plain', 'start')] locked = [e for e in events if (e['kind'], e['event']) == ('locked', 'start')] skipped = [e for e in events if (e['kind'], e['event']) == ('locked', 'skip')] if len(plain) >= 2 and skipped: break time.sleep(0.2) else: raise AssertionError('two real cron ticks were not observed') assert len(plain) == 2 and len(locked) == 1 and len(skipped) == 1 assert plain[1]['monotonic'] - plain[0]['monotonic'] < 70 assert not any(e['event'] == 'end' and e['pid'] == plain[0]['pid'] for e in events) print(json.dumps({'actual_cron_starts': len(plain), 'overlap_observed': True, 'seconds_between_starts': round(plain[1]['monotonic']-plain[0]['monotonic'], 3), 'locked_starts': len(locked), 'locked_skips': len(skipped)}), flush=True) finally: daemon.terminate() stop.touch() daemon.communicate(timeout=5) subprocess.run(['crontab', '-r'], check=True) deadline = time.monotonic() + 5 while time.monotonic() < deadline: events = read_events() started = {e['pid'] for e in events if e['event'] == 'start'} ended = {e['pid'] for e in events if e['event'] == 'end'} if started <= ended: break time.sleep(0.1) else: raise AssertionError('test workers did not finish') 两条每分钟执行的记录会运行原本持续七十秒的工作进程。plain 分支没有锁;locked 分支使用共享文件和非阻塞独占 flock,未取得锁时记录 skip 并退出。每个事件包含进程标识和单调时间,能够直接区分不同实例,并检查第二次启动发生时第一次是否仍在执行,而不是仅根据配置推测并发。 ...

九月 30, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边说明临时文件应原子创建。

在可信位置原子创建临时文件

共享临时目录是其他用户也可能写入的命名空间,并不意味着里面每个文件都公开可读;内容访问仍受文件权限约束。真正危险的假设,是以为选定一个路径,就已经拥有这个名字对应的对象。应原子地创建新对象,并继续使用返回的句柄,而不是信任一个可预测名字,或先检查它不存在、稍后再打开。 名字归属与内容访问是两回事 Linux 的 /tmp 常用模式 1777,即宽松的目录写入、搜索权限,加上 sticky 位。sticky 限制其他用户删除或重命名目录项,却不让现有文件自动保密,也不提前保留尚未创建的名字。文件属主、目录属主、能力及其他访问控制仍然重要。应检查实际环境,不能假定所有操作系统都采用同一路径、挂载方式和权限。 普通写入打开可能跟随预先存在的符号链接。O_CREAT|O_EXCL 拒绝已经存在的最后一个路径名称,包括符号链接。O_NOFOLLOW 则拒绝最后一段是符号链接的情况,但单独使用时不会拒绝既有普通文件,也不保护所有祖先目录。这些是不同契约。应使用可信父目录和适当的临时文件接口,不能把一个标志当成通用路径安全方案。 创建模式也只在真正新建文件时生效。以 O_CREAT 和模式 0600 打开已有文件,不会收紧它原先的权限。随机名字降低碰撞概率,但安全性质来自排他创建,而不只是字符串看起来难以猜测。只生成名字的接口仍留下一段空隙,其他进程可以在真正创建之前占用该名字,因此先检查再创建也不能消除竞争窗口。 位置设置不等于隔离 Python 的 tempfile.mkstemp 安全地创建并打开文件,调用方负责关闭和删除。TemporaryDirectory 提供受限目录和正常退出时的清理。TMPDIR 影响位置选择,却不会自己创建私有目录,也不验证隔离策略。Python 会缓存默认目录选择;如果操作必须使用某个位置,应明确传入 dir=,不要依赖运行中修改环境变量来重新选择。 systemd 的 PrivateTmp= 可以给服务单独的临时文件命名空间,但它改变共享行为,并有平台与 unit 条件,不等同于设置一个环境变量。服务分别使用独立命名空间时,不能依赖跨服务的 /tmp 路径通信。下方本地实验不会启用或验证 systemd 隔离,也不能据此宣称部署已经具有相同隔离边界。 tmpfs 随使用量分配空间,在可用且被允许时可以使用交换空间。没有指定大小时,默认容量上限是物理内存的一半;这是上限,不是挂载时预留的内存。数据分配额度和 inode 数量都可能分别阻止后续工作。/tmp 也不一定挂在 tmpfs 上,因此容量监控应依据实际挂载与负载,不能把目录名字直接当成存储类型。 ...

九月 26, 2027 · 2 分钟 · XB Field Notes
进程组信号主题封面:浅色网格上放置标题,旁侧是相连的观察、判断与行动三个框。

进程组选择信号接收者,不等于进程家族树

进程组是内核维护的一组进程,通过进程组编号 PGID 标识,可用于向一个作业投递信号等操作。父子关系与组成员关系是两个维度:有亲缘关系的进程可以属于不同组,组长也不必始终存活,其他成员仍可继续属于原组。理解这些关系,才能判断一个控制动作到底覆盖哪些进程,以及某个进程退出意味着什么。 零号信号可以在不投递信号的情况下检查存在性与权限,但不是存活或完成确认。僵尸进程仍可能存在,检查之后目标状态也可能变化。因此,应保留本例通过等待回收确认完成的步骤,不能用一次成功的权限探测替代退出结果,更不能据此判断业务工作已经安全结束。 坏掉的样子 向单个进程发信号,不会自动向它的所有子进程发信号。向进程组发信号,选择的是当前组成员,也不是整棵后代树。创建新会话或新组的子进程,可能已经位于目标范围之外;反过来,多个兄弟进程可以刻意共享一个组,即使任何一个都不是另一个的父进程。进程树图不能直接替代组成员清单。 组投递也不保证所有接收者作出相同响应。一个成员可能采用默认动作,另一个安装处理函数,还有成员可能忽略或阻塞允许处理的信号。因此,组操作不会自动形成原子停止,也不能证明组内全部工作完成。每个进程的信号处理方式和应用关停协议仍然决定最终结果,需要分别观察投递、响应与退出。 组长退出不等于整个组退出。只要还有成员,PGID 就可能继续有效,即使组长已经被父进程回收。只观察组长状态的监督程序,可能在成员仍运行时报告结束。组身份应与实际生命周期管理结合,不能被当作一批启动工作永久不变的标识,也不能单凭领导进程消失就推断所有资源都已释放。 快速验证 把脚本保存为 groups.py,在具有 Python 3.11 或更新版本的一次性 Linux 环境中运行 python3 groups.py。本轮在 Linux 6.18.44 和 Python 3.12.14 上重新执行了该样本。process_group 选项在执行程序前安排进程组,start_new_session 请求新会话。所有收到信号的进程,都由此样本创建: import os,signal,subprocess,sys worker=r''' import os,signal,sys if sys.argv[1]=='ignore-term':signal.signal(signal.SIGTERM,signal.SIG_IGN) signal.signal(signal.SIGUSR1,lambda *_:print('USR1',flush=True)) print(os.getpid(),os.getpgrp(),os.getsid(0),flush=True) while True:signal.pause() ''' children=[] def launch(role,**settings): process=subprocess.Popen([sys.executable,'-c',worker,role],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,**settings) children.append(process) identity=tuple(map(int,process.stdout.readline().split())) assert len(identity)==3 and identity[0]==process.pid return process,identity try: leader,first=launch('default',process_group=0) member,second=launch('ignore-term',process_group=leader.pid) outsider,third=launch('default',start_new_session=True) group=leader.pid assert first[1]==second[1]==group and first[2]==second[2]==os.getsid(0) assert third==(outsider.pid,outsider.pid,outsider.pid) assert group!=os.getpgrp() and third[1]!=group print('two sibling children share a group; another sibling starts a separate session and group') os.killpg(group,signal.SIGTERM) assert leader.wait(timeout=5)==-signal.SIGTERM assert member.poll() is None and outsider.poll() is None assert os.getpgid(member.pid)==group print('group TERM terminates the default recipient, leaves the ignoring member alive, and excludes the other group') os.killpg(group,signal.SIGUSR1) assert member.stdout.readline().strip()=='USR1' print('the group remains addressable after its leader has exited and been reaped') os.killpg(group,signal.SIGKILL) assert member.wait(timeout=5)==-signal.SIGKILL assert outsider.poll() is None outsider.terminate();assert outsider.wait(timeout=5)==-signal.SIGTERM print('cleanup reaps all three children; signaling a group did not signal the whole family tree') finally: for process in children: if process.poll() is None:process.kill();process.wait() 第一个子进程新建进程组,PGID 等于自己的 PID。第二个子进程在控制进程所在会话中加入该组。第三个子进程创建独立会话,同时成为会话首领和进程组长。脚本读取真实 PID、PGID 和会话编号,而不是仅根据请求参数推测安排已经生效,这让实验身份与操作目标可以直接核对。 ...

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

符号链接保存路径,不永久绑定目标身份

符号链接是保存目标路径的文件系统对象。跟随链接时,系统按操作发生时的状态解析这段路径;链接并没有永久绑定目标 inode。这个区别解释了为什么符号链接可以跨文件系统、为什么会变成悬空链接,以及为什么保存的目标文本完全没变,最终访问对象却可能改变。理解链接对象与当前解析目标,是正确使用和诊断链接的基础。 坏掉的样子 相对目标以符号链接所在目录为基准,而不是以调用进程的当前工作目录为基准。因此移动链接可能改变其含义;移动或重命名目标,则可能让链接悬空。部署过程如果调整目录结构,却没有核对这些解析基准,熟悉的链接就可能悄悄访问另一个文件。仅看 readlink 打印的文本没有变化,无法证明对象仍相同。 不同存在性检查回答不同问题。跟随链接的操作可能报告目标缺失,而检查目录项本身的操作仍能找到链接。同样,宽松路径解析函数可以在最终目标不存在时返回绝对字符串。规范化后的路径不是可用文件当前存在的证明,更不是文件身份的永久凭据;诊断必须说明自己检查了链接对象,还是跟随之后的目标。 当其他参与者能够修改相关目录项时,先检查路径再打开,会留下检查与使用之间的时间窗口。给路径加引号可以保护 shell 参数解析,却不能冻结文件系统解析。普通 open 的 O_NOFOLLOW 拒绝最后一个组件是符号链接,不会自动禁止前面目录组件中的链接。把参数安全、路径安全和对象稳定性混为一谈,会造成错误边界。 快速验证 把脚本保存为 symlinks.py,在一次性 Linux 环境中运行 python3 symlinks.py。实测使用 Linux 6.12.76 和 Python 3.11.2。脚本创建无害临时文件,并主动改变自己的链接,因此结果是确定性的,不需要并发攻击者,也不访问无关路径: from pathlib import Path from tempfile import TemporaryDirectory import errno,os def read_no_follow(path): fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW) try:return os.read(fd,100) finally:os.close(fd) with TemporaryDirectory(prefix='xbcatm-symlinks-') as directory: root=Path(directory);first=root/'first';second=root/'second';first.mkdir();second.mkdir() (first/'data').write_text('first');(second/'data').write_text('second') link=first/'link';link.symlink_to('data') assert os.readlink(link)=='data' and link.read_text()=='first' assert link.lstat().st_ino!=link.stat().st_ino moved=second/'link';link.rename(moved) assert os.readlink(moved)=='data' and moved.read_text()=='second' print('moving a relative symlink changes its target because resolution starts at its containing directory') (second/'data').rename(second/'renamed') assert moved.is_symlink() and os.path.lexists(moved) and not moved.exists() assert moved.resolve(strict=False)==second/'data' try:moved.resolve(strict=True) except FileNotFoundError:pass else:raise AssertionError('strict resolution unexpectedly found missing target') moved.unlink();assert (second/'renamed').read_text()=='second' print('dangling link exists as a link; permissive resolution can name a missing target; unlink removes only the link') safe=root/'safe';outside=root/'outside';safe.write_bytes(b'approved');outside.write_bytes(b'replaced') current=root/'current';current.symlink_to('safe');checked=current.resolve(strict=True) assert checked==safe opened=os.open(current,os.O_RDONLY) try: replacement=root/'next';replacement.symlink_to('outside');replacement.replace(current) assert current.read_bytes()==b'replaced' and os.read(opened,100)==b'approved' finally:os.close(opened) print('a checked path string can resolve differently later; an already opened descriptor keeps the prior object') try:read_no_follow(current) except OSError as error:assert error.errno==errno.ELOOP,error else:raise AssertionError('O_NOFOLLOW accepted a final-component symlink') alias=root/'parent-alias';alias.symlink_to(first.name,target_is_directory=True) assert read_no_follow(alias/'data')==b'first' print('O_NOFOLLOW rejects the final symlink but still allows symlinks in earlier path components') 第一个目录中有名为 data 的文件,以及目标文本仅为 data 的链接。通过链接读取,得到 first。lstat 检查链接本身,stat 则跟随链接检查目标,样本确认两者具有不同 inode 身份。这两个结果用于说明它们确实是两个对象,而不是同一对象的两种显示名称。 ...

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

在真正启动工作负载的进程中设置文件上限

shell 内建的 ulimit 改变的是该 shell 及其随后启动进程的资源限制。对于文件描述符,RLIMIT_NOFILE 包含当前生效的软值与硬性上界。当终端显示很宽松的数字,独立受管服务却仍然无法打开文件或接受连接时,关键往往在于这些值从哪里继承。只知道某个命令可以调大数字,并不足以证明改到了真正执行工作负载的进程。 坏掉的样子 软值通常约束新的描述符分配。普通进程可以在现有硬上限内提高软值,也可以降低硬值;提高硬值则需要相应权限,而且仍然受内核约束。因此,启动时继承的硬值太低,可能使应用无法设置自己打算使用的软值。不能只检查应用配置写了多少,还要核对它是否真正有能力应用那个数字,以及失败有没有被正确处理。 子进程修改自身限制,不会改变启动它的父进程。同样,在新终端里执行命令,也不会追溯配置已经由 systemd 启动的守护进程。服务实际父进程和启动配置决定其初始值;shell 配置文件只有在真实启动路径读取并应用时才有效。把交互终端的状态当成机器上所有进程的共同状态,会让排障从一开始就观察错对象。 Bash 的选项选择也需要注意。读取 ulimit -n 通常显示软值,但赋值时如果没有选择 -S 或 -H,会同时修改软硬两项。只想暂时降低软限制的操作者,可能顺带降低硬上限,随后无法再以普通权限恢复。应依据具体 shell 的文档理解语义,不能把几条长得相似的命令当成完全等价。 快速验证 本轮环境没有管理员切换数字身份的权限,因此未重新运行该启动流程;执行记录仍来自此前环境。 在装有 Bash 和 Python 的一次性 Linux 容器中,以管理员身份运行下面的脚本。它通过一个普通数字身份执行实验,管理员控制进程的限制保持不变。实测环境为 Linux 6.12.76、Bash 5.2.15、Python 3.11.2,没有修改任何既有账号配置或服务单元: import resource,subprocess before=resource.getrlimit(resource.RLIMIT_NOFILE) assert before[0]>=64 and before[1]>=64 script=r''' set -eu ulimit -S -n 32 ulimit -H -n 64 python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64) resource.setrlimit(resource.RLIMIT_NOFILE,(48,64)) assert resource.getrlimit(resource.RLIMIT_NOFILE)==(48,64) print('first child inherits 32/64 and raises its own soft limit to 48') CHILD python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64) print('second child still inherits shell values 32/64') CHILD if ulimit -S -n 65 2>/dev/null; then exit 11; fi if ulimit -H -n 128 2>/dev/null; then exit 12; fi ( ulimit -n 16 python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(16,16) print('Bash assignment without -S/-H changed both limits in this subshell') CHILD ) python3 - <<'CHILD' import resource assert resource.getrlimit(resource.RLIMIT_NOFILE)==(32,64) print('parent shell limits survived child changes and rejected invalid increases') CHILD ''' # Run the experiment under an unprivileged numeric identity in this disposable container. result=subprocess.run(['bash','--noprofile','--norc','-c',script],user=61001,group=61001,extra_groups=[], env={'PATH':'/usr/bin:/bin','LC_ALL':'C'},capture_output=True,text=True,timeout=10) assert result.returncode==0,(result.returncode,result.stderr) assert resource.getrlimit(resource.RLIMIT_NOFILE)==before print(result.stdout,end='') print('administrator controller limits remain unchanged') 测试 shell 先明确设置软值三十二、硬值六十四。它启动的第一个 Python 子进程继承这组值,然后在硬值保持六十四的情况下,成功把自己的软值提高到四十八。这展示的是现有上界内允许的调整,不需要子进程拥有管理员权限,也不等于它可以绕过硬限制任意扩大资源范围。 ...

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