Logrotate 在被调用时读取配置,轮转符合条件的日志,并执行选定的保留与压缩动作。何时检查由定时器或其他调度器决定,它不会持续限制文件大小,改名也不会自动重定向应用的打开描述符。完整设置需要把检查频率、文件策略和写者实际重开行为结合起来,才能判断日志确实按预期流转。

坏掉的样子

两次执行之间可能超过 size 阈值,突发写入或调度停止时尤其明显。rotate 限制的是保留代数,不是历史时间,也不覆盖全部临时磁盘使用。因此,阈值乘代数不是硬容量保证;活动文件、检查间隔内增长,以及压缩或复制工作,都需要空间。只看到配置里有两个有限数字,不能证明磁盘不会耗尽。

create 指定替代文件的模式和所有权,不会向应用发送信号。postrotate 可以调用受支持的重开机制,但 HUP 并不普遍表示重载。delaycompress 只把压缩推迟一个轮转周期,无法修复从不放弃旧 inode 的写者。每项指令负责不同动作,需要核对它们是否共同完成实际交接。

脚本可能在文件操作已经成功后失败。Logrotate 返回非零,不会回滚全部改名和创建;未检查文件及写者状态就反复强制轮转,可能再次切分或删除需要的代数。应诊断已经发生的部分结果,而不是把失败理解成没有变化。错误恢复也必须依据当前事实,而不能只看最后退出码。

快速验证

历史上的 root 所有文件示例保持原样,本次没有重跑;当前没有 logrotate 及所需特权实验环境。

把脚本保存为 logrotate-cli.py,在装有 Python、logrotate 和 gzip 的一次性 Linux 容器中,以 root 运行 python3 logrotate-cli.py。实测使用 Linux 6.12.76、Python 3.11.2、logrotate 3.21.0 和 ext4。样本使用私有配置与状态文件,只有一个日志子进程,其 HUP 处理函数明确重开自己的临时日志:

from pathlib import Path
from tempfile import TemporaryDirectory
import gzip,os,stat,subprocess,sys

worker=r'''
import os,signal,sys
path=sys.argv[1];fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600)
def reopen(signum,frame):
    global fd
    os.close(fd);fd=os.open(path,os.O_WRONLY|os.O_APPEND)
    print('reopened',flush=True)
signal.signal(signal.SIGHUP,reopen)
try:
    print('ready',flush=True)
    for line in sys.stdin:
        if line=='QUIT\n':break
        os.write(fd,line.encode());print('wrote',flush=True)
finally:os.close(fd)
'''
assert os.geteuid()==0,'use a disposable container for the root-owned fixture'
with TemporaryDirectory(prefix='xbcatm-logrotate-') as directory:
    root=Path(directory);active=root/'app.log';configuration=root/'logrotate.conf';state=root/'state'
    process=subprocess.Popen([sys.executable,'-c',worker,str(active)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    try:
        assert process.stdout.readline().strip()=='ready'
        configuration.write_text(f'''"{active}" {{
    size 64
    rotate 2
    compress
    delaycompress
    missingok
    notifempty
    create 0600 root root
    postrotate
        /bin/kill -HUP {process.pid}
    endscript
}}
''')
        def append(text):
            process.stdin.write(text+'\n');process.stdin.flush();assert process.stdout.readline().strip()=='wrote'
        def rotate(*options):return subprocess.run(['logrotate',*options,'--state',str(state),str(configuration)],capture_output=True,text=True,timeout=5)
        append('short');initial=active.read_bytes()
        result=rotate('--debug');assert result.returncode==0,result.stderr
        assert active.read_bytes()==initial and not state.exists() and not (root/'app.log.1').exists()
        result=rotate();assert result.returncode==0,result.stderr
        assert state.exists() and not (root/'app.log.1').exists()
        print('debug changes neither logs nor state; a normal below-threshold run records state without rotating')
        first=initial+b'first-'+b'a'*80+b'\n';second=b'second-'+b'b'*80+b'\n';third=b'third-'+b'c'*80+b'\n'
        for number,payload in enumerate([first,second,third],1):
            append((payload[len(initial):] if number==1 else payload).decode().rstrip('\n'))
            result=rotate();assert result.returncode==0,result.stderr
            assert process.stdout.readline().strip()=='reopened'
            assert active.read_bytes()==b'' and stat.S_IMODE(active.stat().st_mode)==0o600
            assert (root/'app.log.1').read_bytes()==payload
            if number>=2:
                with gzip.open(root/'app.log.2.gz','rb') as handle:assert handle.read()==(first if number==2 else second)
            assert not (root/'app.log.3.gz').exists()
        append('new-active');assert active.read_bytes()==b'new-active\n'
        print('three actual rotations reopen the writer, delay compression one generation, retain two archives, and preserve the expected bytes')
        failed=root/'failed.log';failed.write_bytes(b'failure-fixture')
        bad=root/'failed.conf';bad.write_text(f'''"{failed}" {{
    size 1
    rotate 1
    create 0600 root root
    postrotate
        /bin/false
    endscript
}}
''')
        result=subprocess.run(['logrotate','--state',str(root/'failed.state'),str(bad)],capture_output=True,text=True,timeout=5)
        assert result.returncode!=0
        assert (root/'failed.log.1').read_bytes()==b'failure-fixture' and failed.read_bytes()==b''
        print('a failed postrotate command returns failure after rename/create side effects have already happened')
        output,error=process.communicate('QUIT\n',timeout=5);assert process.returncode==0,error
    finally:
        if process.poll() is None:process.kill();process.wait()

配置选择六十四字节阈值、两代保留、延迟一个周期的压缩,以及 root 所有、0600 模式的新文件。钩子只向受控子进程已知 PID 发信号。写者对写入和重开都发出确认,让控制进程验证交接,而不是仅凭发送命令成功就推测新路径已经被使用。

日志较小时,debug 模式不会改变日志,也不会创建私有状态文件。随后正常调用创建状态,但不轮转低于阈值的日志。两项检查区分配置观察与真实修改,并证明一次成功执行可以合理地不发生轮转。不能把工具没有切文件直接当作调度失败,也不能把调试输出当成操作已执行。

脚本接着写入超过阈值的数据,分三次调用 logrotate,每次之前加入已知内容。每轮创建空的 0600 活动文件,子进程确认已重开;最新归档作为第一代保持未压缩,前一代则成为 gzip 压缩的第二代。结果来自实际 CLI 操作与文件检查,没有只根据配置文本推测轮转名称。

样本解压较老归档并比较精确字节。第三次轮转后,第二代包含第二次内容,第一代包含第三次内容,不再保留第三代压缩文件。最后一条记录进入当前活动文件。这同时验证保留、压缩与写入位置,而不只是确认某些看起来正确的文件名已经出现。

第二份私有配置刻意执行失败的 postrotate 命令。Logrotate 返回失败,但旧内容已经位于第一代,新活动文件也已创建且为空。这个受控错误展示退出失败后仍然存在的副作用。最后样本停止并回收日志子进程,删除所有临时文件;它没有修改系统原有轮转配置或其他服务日志。

把策略作为完整流程运行

状态文件应与预期轮转任务一致,并在安排并发执行时考虑锁定行为。不同状态文件不代表共享历史。debug 适合审阅判断,force 会改变是否轮转的条件,不能把强制执行成功当作普通调度会在预期时间触发的证据。需要分别验证配置判定、调用频率和真实文件结果。

指令应按目标触发条件选择。大小和时间策略有具体交互与顺序规则,把几个熟悉词放在一起,不会自动形成逻辑与条件。应针对代表性的小文件、大文件、空文件和缺失文件核对生效策略,还要检查应用身份能否写入新建文件。新文件存在,并不证明其模式和所有权适合真实写者。

运行中应同时观察定时检查和重开是否成功、文件系统余量,以及实际保留的历史窗口。如果必须使用 copytruncate,应明确接受并测试复制到截断之间的丢失窗口。保留契约要求时,还要独立转发或备份日志,并验证代表性旧记录经过压缩、轮转和恢复之后仍然可读,才能确认历史确实可用。

延伸阅读

来源

  • logrotate 3.21.0 manual,size、rotate、create、压缩、状态、调试与脚本失败,已核验 2026-09-12。
  • rename(2),重命名路径为何不会重定向存活写者,已核验 2026-09-12。
  • signal(7),应用定义的 HUP 行为与默认终止,已核验 2026-09-12。