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 修改时间的测试,有时得不到想象中的删除结果。

只把年龄说明改成 m:1s,选择文件修改时间后,再次清理就删除了同一个刚创建的文件。这证明时间戳选择改变的是策略,不是显示格式。随后脚本恢复默认规则;只看 mtime 是需要结合应用访问模式评估的有意选择,并不天然比默认规则更好。尤其是读多写少的数据,采用不同时间基准可能产生明显不同的保留结果。

下一阶段创建普通文件、排除子树,以及指向清理树外另一份无害文件的符号链接。等待相关时间戳充分变旧,再创建新文件并清理,旧普通条目和链接消失,新文件及排除子树仍在,链接指向的目标也保持完整,配置中的 /work 目录本身继续存在。这样同时核对需要删除与必须保留的两类对象,避免验证只有单一成功路径。

清理期间,普通旧文件始终被一个描述符打开。虽然路径已经消失,已有描述符仍能读到原来的内容,直到它被关闭。删除目录项与释放所有打开引用背后的存储,是不同事件。因此,如果清理确实删掉了名字,却没有释放预期空间,仍持有已删文件的进程就是一个需要检查的具体原因,不能仅靠再次扫描目录判断没有数据占用。

脚本比较真实模式、路径存在性和内容,不会只把零退出码当成预期删除已经完成。结束时删除整个替代根目录,主机已有临时文件及日常策略都未改变。实验范围保持明确,也让多次重跑能够核对相同的创建、判断与清理语义,而不是碰巧依赖机器上某份未知配置。

让维护策略匹配数据生产者

应检查发行版的 tmpfiles 创建与清理单元,明确规则什么时候真正执行。文件在一秒后符合条件,不代表恰好一秒后就被删除,清理频率和当时负载都会影响时点。定时器未活动或清理命令失败,也可能让符合条件的文件长期留下。年龄阈值与调度周期需要一起理解,不能只改其中一个数字就承诺结果。

年龄策略不能限制生产者在两次清理之间写入多少字节。高写入量、近期访问、排除路径和未关闭描述符都会影响实际保留空间。容量必须有界时,应采用合适的存储限制并监控磁盘,同时排查产生数据的应用,不能一味缩短保留时间,直到有用诊断资料被提前删掉,却仍没有控制写入来源。

应区分服务的运行时数据、缓存与持久状态。重启后仍然需要的数据,不应误继承临时文件删除策略。如果受管理服务目录已经表达了所需所有权和生命周期,就应复用该机制。增加 tmpfiles 规则应对应具体维护需求,并同时验证符合条件的数据确实被删除、必须保留的数据仍然存在,让策略和业务语义保持一致。

延伸阅读

来源