b4f510d1ad
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
4.9 KiB
Python
Executable File
138 lines
4.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""로그 로테이션 — ~/.openclaw/logs 크기 상한 유지 (LLM 미경유).
|
|
|
|
로그가 무한정 쌓여 1.0GB 가 된 걸 정리하고, 그 상태로 유지한다.
|
|
대부분은 종료된 claude-session 로그였다(단일 파일 422MB).
|
|
|
|
정책 두 갈래:
|
|
- **오래된 로그**(mtime > STALE_DAYS): 종료된 세션·고아 로그로 보고 trash.
|
|
살아있는 데몬 로그는 매 사이클 쓰이므로 mtime 이 항상 최신이라 여기 걸리지 않는다.
|
|
- **크고 활성인 로그**(size > MAX_MB): 최근 KEEP_LINES 줄만 남기고 **in-place** 축소.
|
|
|
|
⚠️ **in-place 여야 한다** — rename 후 새 파일을 만들면 launchd 데몬은 이미 열어둔
|
|
옛 inode 에 계속 쓰므로 디스크는 그대로고 새 파일만 영원히 비어 있다. 그래서
|
|
같은 fd 를 유지한 채 앞으로 당겨쓰고 truncate 한다.
|
|
⚠️ 이 방식은 데몬이 **O_APPEND** 로 열었을 때만 안전하다(launchd 의 StandardOutPath/
|
|
StandardErrorPath 는 O_APPEND 로 연다). append 가 아니면 데몬 fd 의 offset 이 옛 위치에
|
|
남아 다음 write 가 그 자리에 떨어지며 sparse hole 로 파일 크기가 되돌아간다.
|
|
2026-09-10 behive-web(112MB, 매분 기록)으로 실측해 되돌아가지 않음을 확인했다.
|
|
|
|
⚠️ 게이트웨이 로그는 대상이 아니다 — 2026.9.3 이후 `~/Library/Logs/openclaw/` 로
|
|
옮겨가 본체가 자체 관리한다. `logs/gateway.*` 는 그때 남은 고아라 STALE 로 정리된다.
|
|
|
|
CLI: python3 log_rotate.py [--dry-run]
|
|
launchd: ai.openclaw.log-rotate (매주 일요일 04:00)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
LOG_DIR = os.path.expanduser("~/.openclaw/logs")
|
|
STALE_DAYS = 30 # 이보다 오래 안 쓰인 로그 = 종료된 세션·고아 → trash
|
|
MAX_MB = 20 # 활성 로그 상한
|
|
KEEP_LINES = 3000 # 축소 시 남길 최근 줄 수
|
|
READ_CHUNK = 1 << 20
|
|
|
|
|
|
def _tail_bytes(path: str, keep_lines: int) -> bytes:
|
|
"""파일 끝에서 keep_lines 줄만 역방향으로 읽어 반환."""
|
|
with open(path, "rb") as f:
|
|
f.seek(0, os.SEEK_END)
|
|
end = f.tell()
|
|
buf = b""
|
|
pos = end
|
|
while pos > 0 and buf.count(b"\n") <= keep_lines:
|
|
step = min(READ_CHUNK, pos)
|
|
pos -= step
|
|
f.seek(pos)
|
|
buf = f.read(step) + buf
|
|
lines = buf.splitlines(keepends=True)
|
|
return b"".join(lines[-keep_lines:])
|
|
|
|
|
|
def _shrink(path: str, dry: bool) -> int:
|
|
"""최근 KEEP_LINES 줄만 남기고 in-place 축소. 회수한 바이트 반환."""
|
|
before = os.path.getsize(path)
|
|
data = _tail_bytes(path, KEEP_LINES)
|
|
if len(data) >= before:
|
|
return 0
|
|
if not dry:
|
|
# inode 유지 — 데몬이 열어둔 fd 가 계속 유효해야 한다
|
|
with open(path, "r+b") as f:
|
|
f.write(data)
|
|
f.truncate()
|
|
return before - len(data)
|
|
|
|
|
|
def _trash(paths: list[str], dry: bool) -> None:
|
|
if dry or not paths:
|
|
return
|
|
subprocess.run(["/usr/bin/trash", *paths], check=False,
|
|
capture_output=True)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--dry-run", action="store_true")
|
|
args = ap.parse_args()
|
|
dry = args.dry_run
|
|
|
|
if not os.path.isdir(LOG_DIR):
|
|
print(f"[skip] {LOG_DIR} 없음", file=sys.stderr)
|
|
return 0
|
|
|
|
now = time.time()
|
|
stale_cut = now - STALE_DAYS * 86400
|
|
max_bytes = MAX_MB * 1024 * 1024
|
|
|
|
total_before = 0
|
|
stale: list[str] = []
|
|
shrunk: list[tuple[str, int]] = []
|
|
|
|
for name in sorted(os.listdir(LOG_DIR)):
|
|
if not name.endswith(".log"):
|
|
continue
|
|
path = os.path.join(LOG_DIR, name)
|
|
try:
|
|
st = os.stat(path)
|
|
except OSError:
|
|
continue
|
|
total_before += st.st_size
|
|
|
|
if st.st_mtime < stale_cut:
|
|
stale.append(path)
|
|
elif st.st_size > max_bytes:
|
|
freed = _shrink(path, dry)
|
|
if freed:
|
|
shrunk.append((name, freed))
|
|
|
|
stale_bytes = sum(os.path.getsize(p) for p in stale if os.path.exists(p))
|
|
_trash(stale, dry)
|
|
|
|
tag = "[dry-run] " if dry else ""
|
|
freed_total = stale_bytes + sum(f for _, f in shrunk)
|
|
print(f"{tag}로그 {total_before/1048576:.0f}MB → "
|
|
f"{(total_before - freed_total)/1048576:.0f}MB "
|
|
f"(회수 {freed_total/1048576:.0f}MB)")
|
|
if stale:
|
|
print(f"{tag}오래된 로그 {len(stale)}개 정리 "
|
|
f"({stale_bytes/1048576:.0f}MB, {STALE_DAYS}일 초과)")
|
|
for p in stale[:5]:
|
|
print(f" - {os.path.basename(p)}")
|
|
if len(stale) > 5:
|
|
print(f" … 외 {len(stale)-5}개")
|
|
for name, freed in shrunk:
|
|
print(f"{tag}축소 {name}: -{freed/1048576:.0f}MB "
|
|
f"(최근 {KEEP_LINES}줄 보존)")
|
|
if not stale and not shrunk:
|
|
print(f"{tag}정리 대상 없음")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|