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