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

历史 size 64、rotate 2 样例仅在调用时检查。改名不会重定向打开的描述符,受控写者必须确认重开。第三次轮转后,.1 保存未压缩的第三批内容,.2.gz 保存第二批,第一批已过期。独立的 postrotate 失败案例中,非零返回仍留下改名后的旧内容与空替代文件。阈值、保留代数和 delaycompress 都不是磁盘硬上限,也不能证明写者已交接。
文件框表示代数与描述符目标,不表示时间或字节比例。HUP 在此有效是因为样例显式实现了重开,并非所有应用的共同约定。CLI 结果均为历史记录,本次未重跑。当前文章审核仍待完成;技术与执行检查仍受阻。

坏掉的样子

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

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

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

快速验证

2026-10-19 修复草案:修订后的代码只进行了静态检查与离线命令/文件状态替身测试,没有执行 logrotate 或发送信号。技术与实际运行验证仍然受阻。原始示例的历史运行使用 Linux 6.12.76、Python 3.11.2、logrotate 3.21.0 与 ext4;这些观察都不能验证本次修订。

未来执行时,应把代码保存为 logrotate-cli.py,置于另行授权的一次性 Linux 实验环境,预装 Python 3.11+、logrotate 3.21.0 和 gzip,不挂载宿主目录,也不承载其他工作。独立核实边界后,才可用 root 执行 python3 logrotate-cli.py --isolated-disposable-lab。参数与 root 身份是前提,不是隔离证明;结束后应丢弃整个环境。

私有配置与状态仍独立于系统策略,已知子进程明确实现 HUP 重开。启动过程进入清理保护;非阻塞管道读写采用单调时钟的五秒期限,明确检查 EOF、错误与确认行长度。标准错误写入私有文件,避免无人读取的管道填满。清理分别尝试终止、限时回收及关闭各个流,并保留原始失败;前序清理失败或 logrotate 超时状态不确定时跳过文件删除,随后丢弃环境;删除本身失败时可能只留下部分诊断。即使已回收写者,CLI 超时仍可能留下钩子或压缩子进程。这些期限约束控制流程,不保证不可中断的内核操作或任意后代进程都能按时退出。

from pathlib import Path
from tempfile import mkdtemp
import gzip
import os
import select
import shutil
import stat
import subprocess
import sys
import time


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
    replacement=os.open(path,os.O_WRONLY|os.O_APPEND)
    os.close(fd);fd=replacement
    print('reopened',flush=True)
signal.signal(signal.SIGHUP,reopen)
try:
    print('ready',flush=True)
    for line in sys.stdin:
        if line=='QUIT\n':break
        data=line.encode()
        if os.write(fd,data)!=len(data):raise OSError('short log write')
        print('wrote',flush=True)
finally:os.close(fd)
'''


def expect(process, expected):
    # Raw nonblocking reads: readiness alone does not mean a complete line.
    descriptor = process.stdout.fileno()
    deadline = time.monotonic() + 5
    answer = bytearray()
    while time.monotonic() < deadline:
        remaining = max(0, deadline - time.monotonic())
        if not select.select([descriptor], [], [], remaining)[0]:
            continue
        try:
            byte = os.read(descriptor, 1)
        except BlockingIOError:
            continue
        if not byte:
            raise RuntimeError(f'logger EOF waiting for {expected}')
        answer.extend(byte)
        if byte == b'\n':
            if answer != (expected + '\n').encode():
                raise RuntimeError(f'unexpected logger acknowledgment: {answer!r}')
            return
        if len(answer) > 64:
            raise RuntimeError('logger acknowledgment is too long')
    raise TimeoutError(f'logger did not acknowledge {expected}')


def send_line(process, text):
    descriptor = process.stdin.fileno()
    pending = (text + '\n').encode()
    deadline = time.monotonic() + 5
    while pending and time.monotonic() < deadline:
        remaining = max(0, deadline - time.monotonic())
        if not select.select([], [descriptor], [], remaining)[1]:
            continue
        try:
            written = os.write(descriptor, pending)
        except BlockingIOError:
            continue
        if written == 0:
            raise RuntimeError('logger input made no progress')
        pending = pending[written:]
    if pending:
        raise TimeoutError('logger input deadline expired')


def attempt(errors, name, operation):
    try:
        operation()
    except Exception as error:
        errors.append(f'{name}: {error!r}')


def main():
    if sys.argv[1:] != ['--isolated-disposable-lab']:
        raise SystemExit('requires a separately verified disposable Linux lab')
    if os.geteuid() != 0:
        raise RuntimeError('root is required, but does not prove isolation')
    root = Path(mkdtemp(prefix='xbcatm-logrotate-', dir='/tmp'))
    active = root / 'app.log'
    configuration = root / 'logrotate.conf'
    state = root / 'state'
    process = error_file = None
    rotation_uncertain = False
    try:
        # A regular error file cannot fill an unread stderr pipe.
        error_file = (root / 'logger.stderr').open('wb')
        process = subprocess.Popen([sys.executable, '-c', worker, str(active)],
                                   stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                   stderr=error_file, bufsize=0)
        os.set_blocking(process.stdin.fileno(), False)
        os.set_blocking(process.stdout.fileno(), False)
        expect(process, '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):
            send_line(process, text)
            expect(process, 'wrote')
        def run_rotation(arguments):
            nonlocal rotation_uncertain
            rotation_uncertain = True
            result = subprocess.run(arguments, capture_output=True, text=True, timeout=5)
            rotation_uncertain = False
            return result
        def rotate(*options):
            return run_rotation(['logrotate', *options, '--state', str(state), str(configuration)])
        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
            expect(process, '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 = run_rotation(['logrotate', '--state', str(root / 'failed.state'), str(bad)])
        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')
        send_line(process, 'QUIT')
        assert process.wait(timeout=5) == 0, (root / 'logger.stderr').read_text()
    finally:
        original = sys.exc_info()[1]
        errors = []
        if rotation_uncertain:
            errors.append('logrotate did not return; hook/compressor descendants are unverified')
        if process is not None:
            # Popen.kill is a no-op when this owned child has already completed.
            attempt(errors, 'kill owned logger', process.kill)
            attempt(errors, 'reap logger', lambda: process.wait(timeout=5))
            for name in ('stdin', 'stdout'):
                stream = getattr(process, name)
                if stream is not None:
                    attempt(errors, 'close ' + name, stream.close)
        if error_file is not None:
            attempt(errors, 'close logger error file', error_file.close)
        if not errors:
            attempt(errors, 'remove private files', lambda: shutil.rmtree(root))
        if errors:
            message = 'cleanup incomplete; discard lab: ' + '; '.join(errors)
            if original is not None:
                original.add_note(message)
            else:
                raise RuntimeError(message)


if __name__ == '__main__':
    main()

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

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

脚本接着写入超过阈值的数据,分三次调用 logrotate,每次之前加入已知内容。每轮创建空的 0600 活动文件,子进程确认已重开;最新归档作为第一代保持未压缩,前一代则成为 gzip 压缩的第二代。这些断言要求未来通过真实 CLI 操作与文件检查验证,不能把当前离线替身结果当成实际轮转证据。

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

第二份私有配置刻意执行失败的 postrotate 命令。Logrotate 返回失败,但旧内容已经位于第一代,新活动文件也已创建且为空。这个受控错误展示退出失败后仍然存在的副作用。修订版最后尝试限时清理日志子进程,只有清理成功才删除私有文件。这些仍是等待真实运行核实的断言,不是本轮实际轮转证据;配置目标仍限于实验文件。

把策略作为完整流程运行

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

指令应按目标触发条件选择。在所引版本中,size 与时间条件互斥,后写的条件优先;minsize 要求大小与时间间隔同时满足,maxsize 则允许因大小提前轮转。它们仍然都要等工具被调用才会判断。应针对代表性的小文件、大文件、空文件和缺失文件核对生效策略,还要检查应用身份能否写入新建文件。新文件存在,并不证明其模式和所有权适合真实写者。

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

延伸阅读

来源

  • logrotate 3.21.0 manual,size、rotate、create、压缩、状态、调试与脚本失败,已核验 2026-09-12。

  • rename(2),重命名路径为何不会重定向存活写者,已核验 2026-09-12。

  • signal(7),应用定义的 HUP 行为与默认终止,已核验 2026-09-12。

  • Python 3.11 subprocess,超时与子进程清理语义,已核验 2026-10-19。

  • Python 3.11 os,非阻塞描述符读写,已核验 2026-10-19。