auto: 일일 백업 2026-09-11 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-09-11 02:00:03 +09:00
parent 92d0d96368
commit b4f510d1ad
188 changed files with 4782 additions and 3353 deletions
+137
View File
@@ -0,0 +1,137 @@
#!/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())