日志轮转把持续的数据流分成可以保留、检索、压缩并最终删除的文件。有用策略既定义何时轮转,也定义需要保留哪段历史,还必须考虑应用如何持有输出文件。成功改名与成功把之后日志转入新文件,是运维契约中的不同步骤。只检查归档文件出现,无法证明写入交接已经完成。
坏掉的样子
写者通常长期保留描述符,而不是每写一条记录都重新解析日志路径。改名旧文件并在原位置创建新文件,不会移动已有描述符。因此活动路径可能看起来为空,归档却持续增长;只监控新名字,会误以为应用停止记录。排查时需要把路径状态与进程实际打开对象对应起来。
删除归档名字,也不会关闭仍然引用该 inode 的写者。文件可以在没有普通目录项的情况下继续打开、占用资源并接受写入。因此清理任务报告删除成功,不一定意味着已经回收预期空间,更不代表应用日志已转移。名字消失和最后一个使用者结束,是需要分别确认的条件。
复制后截断可以避免要求写者重开,但复制和截断是两个独立动作。在复制完成之后、截断之前写入的记录,可能从归档和活动文件中同时消失。选择低峰时段可以降低这种交错的概率,却不能把它变成原子无损交接。是否允许这个窗口,应由日志保留要求明确决定。
快速验证
把脚本保存为 rotation.py,在一次性 Linux 环境中运行 python3 rotation.py。实测使用 Linux 6.12.76、Python 3.11.2 和 ext4。样本只操作临时文件,并明确控制写入顺序;copytruncate 案例是底层操作的确定性交错,不是声称偶然碰到了一次竞争:
from pathlib import Path
from tempfile import TemporaryDirectory
import os,shutil
with TemporaryDirectory(prefix='xbcatm-rotation-') as directory:
root=Path(directory);active=root/'app.log';archive=root/'app.log.1'
with active.open('a+b',buffering=0) as writer:
writer.write(b'before\n');identity=os.fstat(writer.fileno()).st_ino
active.rename(archive);active.touch();writer.write(b'after-rename\n')
assert active.read_bytes()==b'' and archive.read_bytes()==b'before\nafter-rename\n'
assert archive.stat().st_ino==identity and active.stat().st_ino!=identity
print('renaming and creating a replacement path leaves an existing writer appending to the archive inode')
archive.unlink();assert os.fstat(writer.fileno()).st_nlink==0
writer.write(b'after-unlink\n');writer.seek(0)
assert b'after-unlink\n' in writer.read() and not archive.exists()
print('deleting the archive name does not close the writer; its unlinked file remains open and writable')
with active.open('ab',buffering=0) as reopened:reopened.write(b'new-active\n')
assert active.read_bytes()==b'new-active\n'
print('closing and reopening the active path switches subsequent writes to the replacement inode')
source=root/'copytruncate.log';copied=root/'copytruncate.log.1'
with source.open('a+b',buffering=0) as writer:
writer.write(b'before-copy\n');shutil.copyfile(source,copied)
writer.write(b'between-copy-and-truncate\n')
os.ftruncate(writer.fileno(),0);writer.write(b'after-truncate\n')
assert copied.read_bytes()==b'before-copy\n' and source.read_bytes()==b'after-truncate\n'
assert b'between-copy-and-truncate' not in copied.read_bytes()+source.read_bytes()
print('deterministic copy-then-truncate interleaving loses the write placed in that gap')
第一个写者追加 before,控制流程再把日志改名并创建新的活动路径。同一打开写者继续追加 after-rename,两条记录都出现在归档中,替代文件仍然为空。inode 检查确认写者保留原对象,而活动路径已经标识另一个文件。这样直接验证名字改变不会自动重定向进程手里的引用。
随后在写者仍打开时 unlink 归档,链接数变为零,再追加的记录仍可通过保留引用读取,归档路径却已经不存在。这解释了磁盘空间事故中为什么必须分别调查路径删除和文件寿命:清理程序看到名字消失,并不能证明仍在记录日志的进程已经放弃该对象。
关闭旧写者、重新打开活动路径后,后续记录才进入替代文件。这是服务文档所规定的日志重开机制必须完成的核心交接。发送一个约定俗成的信号,只有在应用确实实现相应行为、且结果被观察时才有用;不能单凭发送成功,就推断新日志开始增长、旧描述符已经关闭。
第二组先复制含 before-copy 的日志,再向原文件写一条中间记录,随后截断原文件并追加 after-truncate。归档只有第一条,活动文件只有最后一条,中间记录在两者中都不存在。这个顺序直接展示 copytruncate 文档所描述的丢失窗口,不需要用模糊的“可能有一点风险”代替具体数据去向。
脚本关闭所有引用并删除临时文件。它检查运行期间可见性和数据位置,没有验证崩溃持久性或远端备份。成功 rename、压缩完成或归档目录含有预期数量的名字,都不能自动推导这些额外属性。不同保留要求应有对应验证,避免用一项局部成功替代整个恢复目标。
同时预算时间、增长和检索
大小阈值只在轮转工具执行时被检查,不是持续强制的最大文件尺寸。例如十 MiB 阈值、每六十秒检查,若写速率被限制在每秒一 MiB,下次检查前可能长到约七十 MiB。检查延迟或生产速率没有上界,都会使这个示例增长界限失效,因此配置阈值不能直接当作磁盘硬配额。
容量应包含活动文件、保留代数、压缩或复制临时空间,以及仍被打开的旧文件。保留四代不自动等于保留四天,频繁轮转可能更快用完这些代数。应同时选择空间预算和能够支持实际事故调查的历史窗口,并核对运行节奏是否真的让两者成立,而不是只优化一个容易看到的数字。
还应验证检索而不只是删除:代表性记录能否跨轮转和压缩文件找到,最早需要的时间区间是否仍存在。如果历史必须抵御主机或磁盘丢失,应保留独立存储副本并测试恢复。本地轮转负责组织和限制本地历史,其成功本身不能证明机器消失后仍可取回记录,需要用独立恢复证据支持这项要求。
来源
- rename(2),名称替换不会重定向既有打开描述,已核验 2026-09-12。
- unlink(2),通过打开引用保留已取消链接的文件,已核验 2026-09-12。
- logrotate 3.21.0 manual,保留指令、大小判断与 copytruncate 丢失窗口,已核验 2026-09-12。