复现而非等日志:容器内直接跑 archive_old_tasks.py 即崩。 1. app/config.py 的 admin_password 是必填字段,cron 环境没有它 → get_settings() 在 import 阶段抛 ValidationError,归档与 VACUUM 全崩。 证据:policy_versions 留 10 行(策略写的留 5 删 5)、scan_tasks_archive 0 行, 而容器 Up 且 RestartCount=0,没有任何存活信号暴露。给占位 env,不把真实口令复制进 cron。 2. VACUUM 不能在事务块里跑(ActiveSqlTransaction),原代码用了 engine.begin(), 改用 AUTOCOMMIT 连接;实测 6 张表 0.25s 完成。 3. ValidationError 把整个 settings dict 打进 stderr,被 run() 原样写进 docker 日志, 含 DB 连接串与 master key 尾部 → 加 _redact() 落盘前抹掉,验证输出中不可见。 cron 不监听 8443,镜像层 HEALTHCHECK 恒为 unhealthy,调度器真挂掉时该信号失效,故关闭。
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""MeshSafe 定时任务调度器 — 零依赖,Docker 容器前台常驻。
|
||
|
||
和 api 共享同一个镜像,容器 entrypoint 换成本脚本即可。
|
||
用法: docker run meshsafe:latest python3 scripts/cron_entrypoint.py
|
||
|
||
支持的任务:
|
||
每天 03:00 归档 + 清理 DB (archive_old_tasks.py --apply)
|
||
每周一 03:10 VACUUM ANALYZE (维护表统计信息)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
|
||
sys.path.insert(0, "/app")
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||
)
|
||
log = logging.getLogger("scheduler")
|
||
|
||
|
||
_SECRET_ENV_KEYS = ("MESHSAFE_DB_URL", "MESHSAFE_MASTER_KEY", "MESHSAFE_ADMIN_PASSWORD",
|
||
"MESHSAFE_API_KEY", "MESHSAFE_API_CLIENTS")
|
||
|
||
|
||
def _redact(text: str) -> str:
|
||
"""崩溃日志里 pydantic 会把整个 settings dict(含 DB 口令)原样打出来,落盘前抹掉。"""
|
||
out = text
|
||
for key in _SECRET_ENV_KEYS:
|
||
val = os.environ.get(key, "")
|
||
if val and len(val) >= 8:
|
||
out = out.replace(val, "***")
|
||
return out
|
||
|
||
|
||
def run(cmd: list[str], label: str) -> None:
|
||
"""跑一个命令,捕获输出并记日志。"""
|
||
log.info(f"[{label}] 开始执行: {' '.join(cmd)}")
|
||
try:
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
|
||
if proc.stdout:
|
||
for line in proc.stdout.strip().splitlines():
|
||
log.info(f"[{label}] {_redact(line)}")
|
||
if proc.stderr:
|
||
for line in proc.stderr.strip().splitlines():
|
||
log.warning(f"[{label}] {_redact(line)}")
|
||
if proc.returncode == 0:
|
||
log.info(f"[{label}] ✅ 完成")
|
||
else:
|
||
log.error(f"[{label}] ❌ 返回码 {proc.returncode}")
|
||
except subprocess.TimeoutExpired:
|
||
log.error(f"[{label}] ❌ 超时 (30min)")
|
||
except Exception as e:
|
||
log.error(f"[{label}] ❌ 异常: {e}")
|
||
|
||
|
||
def run_vacuum() -> None:
|
||
"""VACUUM ANALYZE — 维护表统计信息。
|
||
|
||
VACUUM 不能在事务块里跑(psycopg2 会抛 ActiveSqlTransaction),
|
||
所以用 AUTOCOMMIT 连接而不是 engine.begin()。
|
||
"""
|
||
try:
|
||
from app.db import engine
|
||
from sqlalchemy import text
|
||
|
||
tables = [
|
||
"scan_tasks", "scan_tasks_archive",
|
||
"scan_findings", "idempotency_records",
|
||
"policy_versions", "audit_logs",
|
||
]
|
||
with engine.connect() as raw:
|
||
conn = raw.execution_options(isolation_level="AUTOCOMMIT")
|
||
for tbl in tables:
|
||
log.info(f"[VACUUM] {tbl} ...")
|
||
conn.execute(text(f"VACUUM ANALYZE {tbl}"))
|
||
log.info("[VACUUM] ✅ 全部完成")
|
||
except Exception as e:
|
||
log.error(f"[VACUUM] ❌ 异常: {_redact(str(e))}")
|
||
|
||
|
||
def seconds_until(target_hour: int, target_minute: int, weekday: int | None = None) -> float:
|
||
"""计算到下次目标时间的秒数。
|
||
|
||
weekday: 0=周一...6=周日, None=每天
|
||
"""
|
||
now = datetime.now()
|
||
candidate = now.replace(hour=target_hour, minute=target_minute, second=0, microsecond=0)
|
||
|
||
if weekday is not None:
|
||
# 找到本周目标日
|
||
days_ahead = weekday - now.weekday()
|
||
if days_ahead < 0 or (days_ahead == 0 and now >= candidate):
|
||
days_ahead += 7
|
||
candidate += timedelta(days=days_ahead)
|
||
else:
|
||
if candidate <= now:
|
||
candidate += timedelta(days=1)
|
||
|
||
return (candidate - now).total_seconds()
|
||
|
||
|
||
def main() -> None:
|
||
log.info("=" * 60)
|
||
log.info("MeshSafe 定时任务调度器启动")
|
||
log.info(f"PID={os.getpid()} TZ=Asia/Shanghai")
|
||
log.info("任务:")
|
||
log.info(" - 每天 03:00 archive_old_tasks.py --apply")
|
||
log.info(" - 每周一 03:10 VACUUM ANALYZE")
|
||
log.info("=" * 60)
|
||
|
||
while True:
|
||
# 计算两个任务下次执行时间,取更早的那个
|
||
secs_archive = seconds_until(3, 0, weekday=None) # 每天 03:00
|
||
secs_vacuum = seconds_until(3, 10, weekday=0) # 周一 03:10
|
||
|
||
if secs_archive <= secs_vacuum:
|
||
log.info(f"下一个任务: 归档 → {secs_archive/3600:.1f}h 后")
|
||
time.sleep(secs_archive)
|
||
run(
|
||
[sys.executable, "/app/scripts/archive_old_tasks.py", "--apply"],
|
||
"ARCHIVE",
|
||
)
|
||
# 跑完后清下一次时间 (避免在 03:00 这一刻两个任务重叠)
|
||
continue
|
||
else:
|
||
log.info(f"下一个任务: VACUUM → {secs_vacuum/3600:.1f}h 后")
|
||
time.sleep(secs_vacuum)
|
||
run_vacuum()
|
||
continue
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|