App logs kept forever: nightly gzip on 249 (LOG_FILE_ROTATION=external), hourly pull to 247 via a forced-command key, health warns if the archive stops

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 17:54:05 +03:30
parent f4831e93a7
commit 3559c43902
10 changed files with 180 additions and 7 deletions

View File

@@ -275,6 +275,24 @@ def _offsite_warnings(offsite: dict) -> list[str]:
return [msg] if msg else [] return [msg] if msg else []
# بیش از این یعنی آرشیوِ شبانه (`ops/applogs/archive-app-log.sh`) چند روز است اجرا نشده؛
# با ترافیکِ ۶۰۰ کاربرِ فعال یک روز حدودِ ۳۰ مگ است (برآورد، نه سنجش).
APP_LOG_MAX_BYTES = 256 * 2**20
def _app_log_warnings() -> list[str]:
"""با چرخشِ بیرونی، برنامه app.log را پاک نمی‌کند؛ اگر آرشیوِ شبانه بخوابد فایل بی‌انتها بزرگ می‌شود."""
if not settings.LOG_DIR or settings.LOG_FILE_ROTATION != 'external':
return []
try:
size = os.path.getsize(os.path.join(settings.LOG_DIR, 'app.log'))
except FileNotFoundError:
return []
if size > APP_LOG_MAX_BYTES:
return [f'app.log {size // 2**20} مگ است: آرشیوِ شبانه‌ی لاگ (zareian-applog-archive.timer) اجرا نشده.']
return []
def _warnings() -> list[str]: def _warnings() -> list[str]:
""" """
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند. چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
@@ -313,6 +331,7 @@ def _warnings() -> list[str]:
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها '
'«در دسترس نیست» می‌دهد تا حساب شارژ شود.' '«در دسترس نیست» می‌دهد تا حساب شارژ شود.'
) )
out.extend(_app_log_warnings())
backend = settings.CACHES['default']['BACKEND'] backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend: if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')

View File

@@ -1060,13 +1060,28 @@ OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
# بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است. # بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است.
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20 OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛
# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و
# فشرده می‌کند و ۲۴۷ آن را برای همیشه نگه می‌دارد. WatchedFileHandler پس از جابه‌جایی فایلِ تازه باز می‌کند،
# که با ۴ پروسه‌ی gunicorn امن‌تر از چرخشِ داخلیِ RotatingFileHandler است (آن هر پروسه جدا می‌چرخاند).
LOG_FILE_ROTATION = os.environ.get('LOG_FILE_ROTATION', 'size')
if LOG_FILE_ROTATION not in ('size', 'external'):
raise ImproperlyConfigured(f"LOG_FILE_ROTATION باید 'size' یا 'external' باشد، نه {LOG_FILE_ROTATION!r}")
if LOG_DIR: if LOG_DIR:
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
LOGGING['handlers']['file'] = { if LOG_FILE_ROTATION == 'external':
'class': 'logging.handlers.RotatingFileHandler', LOGGING['handlers']['file'] = {
'filename': os.path.join(LOG_DIR, 'app.log'), 'class': 'logging.handlers.WatchedFileHandler',
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate 'filename': os.path.join(LOG_DIR, 'app.log'),
'backupCount': 5, 'formatter': 'json',
'formatter': 'json', }
} else:
LOGGING['handlers']['file'] = {
'class': 'logging.handlers.RotatingFileHandler',
'filename': os.path.join(LOG_DIR, 'app.log'),
'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate
'backupCount': 5,
'formatter': 'json',
}
LOGGING['root']['handlers'].append('file') LOGGING['root']['handlers'].append('file')

View File

@@ -0,0 +1,26 @@
import os
import tempfile
from django.test import SimpleTestCase, override_settings
from config import health
class AppLogSizeWarningTests(SimpleTestCase):
"""با چرخشِ بیرونی برنامه app.log را پاک نمی‌کند؛ اگر آرشیوِ شبانه بخوابد /api/health باید بگوید."""
def _with_log(self, size, rotation):
d = tempfile.mkdtemp()
with open(os.path.join(d, 'app.log'), 'wb') as f:
f.truncate(size)
with override_settings(LOG_DIR=d, LOG_FILE_ROTATION=rotation):
return health._app_log_warnings()
def test_big_log_with_external_rotation_warns(self):
self.assertEqual(len(self._with_log(health.APP_LOG_MAX_BYTES + 1, 'external')), 1)
def test_small_log_is_quiet(self):
self.assertEqual(self._with_log(1024, 'external'), [])
def test_size_rotation_never_warns(self):
self.assertEqual(self._with_log(health.APP_LOG_MAX_BYTES + 1, 'size'), [])

33
ops/applogs/archive-app-log.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند.
# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه
# می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود.
# این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده
# (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد.
set -euo pipefail
LOGS=/home/administrator/mathapp-v2/logs
ARCH="$LOGS/archive"
mkdir -p "$ARCH"
chmod 750 "$ARCH"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
# app.log.1..5 از RotatingFileHandlerِ پیشین (پیش از LOG_FILE_ROTATION=external) هم یک بار بایگانی می‌شوند.
for f in "$LOGS"/app.log.[0-9]*; do
[ -e "$f" ] || continue
n=${f##*.}
mv "$f" "$ARCH/app-$stamp-old$n.log"
done
if [ -s "$LOGS/app.log" ]; then
mv "$LOGS/app.log" "$ARCH/app-$stamp.log"
sleep 5 # نوشتنِ در جریانِ workerها روی فایلِ قدیمی تمام شود
fi
for f in "$ARCH"/app-*.log; do
[ -e "$f" ] || continue
gzip -9 "$f"
chmod 640 "$f.gz"
done
find "$ARCH" -name 'app-*.log.gz' -mtime +30 | while read -r gz; do
[ -e "$gz.shipped" ] && rm -f -- "$gz" "$gz.shipped"
done
echo "archived $(ls "$ARCH"/app-*.log.gz 2>/dev/null | wc -l) files in $ARCH"

26
ops/applogs/pull-app-logs.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# روی ۲۴۷، هر ساعت (zareian-applog-pull.timer). بایگانیِ فشرده‌ی لاگِ برنامه را از ۲۴۹ می‌گیرد و
# **برای همیشه** در ~/zareian-pgbackup/app-logs/ نگه می‌دارد (تصمیمِ مالک ۲۰۲۶-۱۰-۱۳؛ برآورد حدودِ ۱ گیگ در سال
# با ۶۰۰ کاربرِ فعال). هیچ فایلی این‌جا پاک نمی‌شود. کلید فقط serve-archive.sh را اجرا می‌کند.
set -euo pipefail
DEST=/home/administrator/zareian-pgbackup/app-logs
mkdir -p "$DEST"
r() { ssh -i /home/administrator/.ssh/zareian_applogs -o BatchMode=yes -o ConnectTimeout=20 \
administrator@192.168.200.164 "$@"; }
free_kb=$(df --output=avail -k "$DEST" | tail -1)
[ "$free_kb" -gt 3000000 ] || { echo "disk too full ($free_kb KB free); skipping" >&2; exit 1; }
got=0
for name in $(r list); do
[[ "$name" =~ ^app-[0-9]{8}T[0-9]{6}Z(-old[0-9]+)?\.log\.gz$ ]] || { echo "skip odd name: $name" >&2; continue; }
if [ ! -s "$DEST/$name" ]; then
r get "$name" > "$DEST/.$name.part"
gzip -t "$DEST/.$name.part"
chmod 600 "$DEST/.$name.part"
mv "$DEST/.$name.part" "$DEST/$name"
got=$((got + 1))
fi
r ack "$name"
done
echo "fetched $got new; $(ls "$DEST" | wc -l) files, $(du -sh "$DEST" | cut -f1) in $DEST"

17
ops/applogs/serve-archive.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# روی ۲۴۹: فرمانِ اجباریِ کلیدِ zareian-applogs (authorized_keys با restrict,command="…این فایل").
# آن کلید (روی ۲۴۷) جز این سه کار هیچ کاری نمی‌تواند: شِل، پورت و فایلِ دیگری ندارد.
# list نامِ فایل‌های بایگانی
# get NAME محتوای یک فایل
# ack NAME «۲۴۷ گرفت و سالم بود» → NAME.shipped (پس از ۳۰ روز این‌جا پاک شود)
set -euo pipefail
ARCH=/home/administrator/mathapp-v2/logs/archive
read -r cmd name extra <<<"${SSH_ORIGINAL_COMMAND:-}"
[ -z "${extra:-}" ] || { echo "bad request" >&2; exit 2; }
valid() { [[ "$1" =~ ^app-[0-9]{8}T[0-9]{6}Z(-old[0-9]+)?\.log\.gz$ ]] && [ -f "$ARCH/$1" ]; }
case "$cmd" in
list) cd "$ARCH" && ls -1 app-*.log.gz 2>/dev/null || true ;;
get) valid "${name:-}" || { echo "no such file" >&2; exit 2; }; cat -- "$ARCH/$name" ;;
ack) valid "${name:-}" || { echo "no such file" >&2; exit 2; }; touch -- "$ARCH/$name.shipped" ;;
*) echo "bad request" >&2; exit 2 ;;
esac

View File

@@ -0,0 +1,8 @@
# روی ۲۴۹.
[Unit]
Description=Zareian MathApp: nightly gzip of app.log into logs/archive
[Service]
Type=oneshot
User=administrator
ExecStart=/home/administrator/mathapp-v2/ops/applogs/archive-app-log.sh

View File

@@ -0,0 +1,10 @@
# روی ۲۴۹. ساعتِ سرور تهران است.
[Unit]
Description=Zareian MathApp: nightly app.log archive
[Timer]
OnCalendar=*-*-* 00:05:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,8 @@
# روی ۲۴۷.
[Unit]
Description=Zareian MathApp: fetch archived app logs from 249 (kept forever)
[Service]
Type=oneshot
User=administrator
ExecStart=/home/administrator/zareian-pgbackup/bin/pull-app-logs.sh

View File

@@ -0,0 +1,11 @@
# روی ۲۴۷.
[Unit]
Description=Zareian MathApp: hourly fetch of archived app logs
[Timer]
OnCalendar=hourly
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target