fix: meshsafe-cron 定时任务自引入以来从未成功执行 — 三处缺陷

复现而非等日志:容器内直接跑 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,调度器真挂掉时该信号失效,故关闭。
This commit is contained in:
2026-11-02 20:26:08 +08:00
parent 1079ca16de
commit 64b682319e
2 changed files with 33 additions and 5 deletions

View File

@@ -151,10 +151,19 @@ services:
- MESHSAFE_ENV=prod - MESHSAFE_ENV=prod
- MESHSAFE_DB_URL=postgresql+psycopg2://meshsafe:${MESHSAFE_DB_PASSWORD:-meshsafe}@postgres:5432/meshsafe - MESHSAFE_DB_URL=postgresql+psycopg2://meshsafe:${MESHSAFE_DB_PASSWORD:-meshsafe}@postgres:5432/meshsafe
- MESHSAFE_MASTER_KEY=${MESHSAFE_MASTER_KEY:?MASTER_KEY_REQUIRED} - MESHSAFE_MASTER_KEY=${MESHSAFE_MASTER_KEY:?MASTER_KEY_REQUIRED}
# cron 不做控制台认证,但 Settings.admin_password 是必填字段,缺它 get_settings()
# 就抛 ValidationError,归档与 VACUUM 全崩。给占位值,不把真实口令复制进 cron
# 容器环境(docker inspect 可见)。
- MESHSAFE_ADMIN_PASSWORD=cron-unused-placeholder
volumes: volumes:
- ./data/logs:/var/log/meshsafe # 归档日志共享目录 - ./data/logs:/var/log/meshsafe # 归档日志共享目录
- ./scripts:/app/scripts:ro # archive_old_tasks.py + cron_entrypoint.py - ./scripts:/app/scripts:ro # archive_old_tasks.py + cron_entrypoint.py
entrypoint: ["python3", "/app/scripts/cron_entrypoint.py"] entrypoint: ["python3", "/app/scripts/cron_entrypoint.py"]
# 镜像层的 HEALTHCHECK 探测 localhost:8443(API 服务端口),cron 容器不监听该端口,
# 因此状态恒为 unhealthy —— 真正调度器挂掉时这个信号完全失效。关掉它,
# 存活与否由 restart: unless-stopped + 日志判断。
healthcheck:
disable: true
# ---------- Nginx(反向代理,内网 HTTP 明文)---------- # ---------- Nginx(反向代理,内网 HTTP 明文)----------
# 外部端口 44380 → 内部 80 → api:8443 # 外部端口 44380 → 内部 80 → api:8443

View File

@@ -25,6 +25,20 @@ logging.basicConfig(
log = logging.getLogger("scheduler") 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: def run(cmd: list[str], label: str) -> None:
"""跑一个命令,捕获输出并记日志。""" """跑一个命令,捕获输出并记日志。"""
log.info(f"[{label}] 开始执行: {' '.join(cmd)}") log.info(f"[{label}] 开始执行: {' '.join(cmd)}")
@@ -32,10 +46,10 @@ def run(cmd: list[str], label: str) -> None:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800) proc = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
if proc.stdout: if proc.stdout:
for line in proc.stdout.strip().splitlines(): for line in proc.stdout.strip().splitlines():
log.info(f"[{label}] {line}") log.info(f"[{label}] {_redact(line)}")
if proc.stderr: if proc.stderr:
for line in proc.stderr.strip().splitlines(): for line in proc.stderr.strip().splitlines():
log.warning(f"[{label}] {line}") log.warning(f"[{label}] {_redact(line)}")
if proc.returncode == 0: if proc.returncode == 0:
log.info(f"[{label}] ✅ 完成") log.info(f"[{label}] ✅ 完成")
else: else:
@@ -47,7 +61,11 @@ def run(cmd: list[str], label: str) -> None:
def run_vacuum() -> None: def run_vacuum() -> None:
"""VACUUM ANALYZE — 维护表统计信息。""" """VACUUM ANALYZE — 维护表统计信息。
VACUUM 不能在事务块里跑(psycopg2 会抛 ActiveSqlTransaction),
所以用 AUTOCOMMIT 连接而不是 engine.begin()。
"""
try: try:
from app.db import engine from app.db import engine
from sqlalchemy import text from sqlalchemy import text
@@ -57,13 +75,14 @@ def run_vacuum() -> None:
"scan_findings", "idempotency_records", "scan_findings", "idempotency_records",
"policy_versions", "audit_logs", "policy_versions", "audit_logs",
] ]
with engine.begin() as conn: with engine.connect() as raw:
conn = raw.execution_options(isolation_level="AUTOCOMMIT")
for tbl in tables: for tbl in tables:
log.info(f"[VACUUM] {tbl} ...") log.info(f"[VACUUM] {tbl} ...")
conn.execute(text(f"VACUUM ANALYZE {tbl}")) conn.execute(text(f"VACUUM ANALYZE {tbl}"))
log.info("[VACUUM] ✅ 全部完成") log.info("[VACUUM] ✅ 全部完成")
except Exception as e: except Exception as e:
log.error(f"[VACUUM] ❌ 异常: {e}") log.error(f"[VACUUM] ❌ 异常: {_redact(str(e))}")
def seconds_until(target_hour: int, target_minute: int, weekday: int | None = None) -> float: def seconds_until(target_hour: int, target_minute: int, weekday: int | None = None) -> float: