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。