diff --git a/docs/PRODUCTION-RESOURCE-MONITORING.md b/docs/PRODUCTION-RESOURCE-MONITORING.md new file mode 100644 index 000000000..848932619 --- /dev/null +++ b/docs/PRODUCTION-RESOURCE-MONITORING.md @@ -0,0 +1,136 @@ +# 小虾 SaaS 生产资源巡检 Runbook + +> 状态:生效中 +> 创建时间:2026-06-23 +> 目的:防止生产主机资源过紧导致 SSH/TLS/业务入口卡死。 + +--- + +## 1. 背景 + +2026-06-23 生产 UAT 期间,公开域名曾出现 TLS 握手超时,SSH 出现 banner exchange 超时。后续确认生产主机只有约 1.7GiB 内存且无 swap,短时间系统压力会影响入口层响应。 + +已完成止血: + +- 添加 `/swapfile` 2GiB。 +- Worker 默认并发限制为 1。 +- `/health` 显示真实发布版本。 + +仍需持续监控: + +- CPU/load。 +- 可用内存和 swap 使用率。 +- 根分区磁盘使用率。 +- Web/API 健康。 +- 关键容器运行状态。 +- API 版本是否符合期望。 + +--- + +## 2. 巡检脚本 + +脚本路径: + +```sh +scripts/production_resource_check.sh +``` + +默认输出: + +```sh +/var/lib/xiaoxia-ci/duty_report.json +``` + +默认检查: + +- 5 分钟 load 是否超过 `CPU 数 * 1.5`。 +- 可用内存是否低于 `256MiB`。 +- swap 是否存在,swap 使用率是否超过 `60%`。 +- 根分区磁盘使用率是否超过 `85%`。 +- `http://127.0.0.1:8001/health` 是否可用。 +- `http://127.0.0.1:3002/` 是否可用。 +- 关键生产容器是否 running/healthy。 + +--- + +## 3. 手工运行 + +在生产主机项目目录执行: + +```sh +cd /var/lib/xiaoxia-saas-production/repo +EXPECTED_VERSION=v0.1.15 sh scripts/production_resource_check.sh +``` + +如果只想生成报告,不指定版本: + +```sh +sh scripts/production_resource_check.sh +``` + +--- + +## 4. 报告格式 + +报告 JSON 包含: + +- `status`:`healthy` 或 `warning`。 +- `alerts`:需要通知老大的告警。 +- `info`:正常信息。 +- `actions`:自动修复动作,目前保持空数组。 +- `metrics`:load、memory、disk、containers、health 等指标。 + +OpenClaw 心跳可继续读取: + +```sh +ssh xiaoxia-server "cat /var/lib/xiaoxia-ci/duty_report.json 2>/dev/null" +``` + +--- + +## 5. 建议 cron + +需要老大确认后再启用,不自动创建。 + +建议每 5 分钟巡检一次: + +```cron +*/5 * * * * cd /var/lib/xiaoxia-saas-production/repo && EXPECTED_VERSION=v0.1.15 sh scripts/production_resource_check.sh >/var/log/xiaoxia-resource-check.log 2>&1 +``` + +--- + +## 6. 告警处理建议 + +### 6.1 可用内存偏低 + +1. 先查看 `free -h` 和 `docker stats --no-stream`。 +2. 确认 Worker 并发是否仍为 1。 +3. 如 swap 持续大量使用,规划升级生产机规格。 + +### 6.2 磁盘超过 85% + +1. 先执行只读检查:`du -h -d 1 /var/lib | sort -h`。 +2. 优先清理旧 release tar、旧 runtime images、Docker build cache。 +3. 清理前必须确认可回滚版本和备份。 + +### 6.3 API/Web 健康失败 + +1. 查看 `docker ps`。 +2. 查看容器日志。 +3. 如 API 重建过,Web 必须 force recreate,避免 nginx 静态 upstream 缓存旧 IP。 + +### 6.4 版本不一致 + +1. 确认 `/health` 返回版本。 +2. 确认生产容器镜像 tag。 +3. 确认 Gitea release task 是否完成。 + +--- + +## 7. 长期建议 + +- 生产机升级到至少 4GiB,推荐 8GiB。 +- Gitea 从生产业务机拆分出去。 +- Worker 继续保持并发限制,并按任务队列规模再做动态扩容。 +- 后续接入正式监控告警系统,而不是只依赖心跳读取 JSON。 diff --git a/scripts/production_resource_check.sh b/scripts/production_resource_check.sh new file mode 100644 index 000000000..b60be0778 --- /dev/null +++ b/scripts/production_resource_check.sh @@ -0,0 +1,162 @@ +#!/bin/sh +set -eu + +REPORT_PATH="${REPORT_PATH:-/var/lib/xiaoxia-ci/duty_report.json}" +API_HEALTH_URL="${API_HEALTH_URL:-http://127.0.0.1:8001/health}" +WEB_HEALTH_URL="${WEB_HEALTH_URL:-http://127.0.0.1:3002/}" +LOAD_WARN_PER_CPU="${LOAD_WARN_PER_CPU:-1.5}" +MEM_AVAILABLE_WARN_MB="${MEM_AVAILABLE_WARN_MB:-256}" +DISK_WARN_PERCENT="${DISK_WARN_PERCENT:-85}" +SWAP_USED_WARN_PERCENT="${SWAP_USED_WARN_PERCENT:-60}" +EXPECTED_VERSION="${EXPECTED_VERSION:-}" + +mkdir -p "$(dirname "$REPORT_PATH")" + +python3 - <<'PY' +import json +import os +import shutil +import subprocess +import time +import urllib.request +from datetime import datetime, timezone + +report_path = os.environ.get("REPORT_PATH", "/var/lib/xiaoxia-ci/duty_report.json") +api_health_url = os.environ.get("API_HEALTH_URL", "http://127.0.0.1:8001/health") +web_health_url = os.environ.get("WEB_HEALTH_URL", "http://127.0.0.1:3002/") +load_warn_per_cpu = float(os.environ.get("LOAD_WARN_PER_CPU", "1.5")) +mem_available_warn_mb = int(os.environ.get("MEM_AVAILABLE_WARN_MB", "256")) +disk_warn_percent = int(os.environ.get("DISK_WARN_PERCENT", "85")) +swap_used_warn_percent = int(os.environ.get("SWAP_USED_WARN_PERCENT", "60")) +expected_version = os.environ.get("EXPECTED_VERSION", "").strip() + +alerts = [] +info = [] +actions = [] +metrics = {} + + +def run(command): + return subprocess.run(command, text=True, capture_output=True, check=False) + + +def add_alert(message): + alerts.append(message) + + +def read_meminfo(): + data = {} + with open("/proc/meminfo", "r", encoding="utf-8") as file: + for line in file: + key, value = line.split(":", 1) + data[key] = int(value.strip().split()[0]) + return data + + +cpu_count = os.cpu_count() or 1 +load1, load5, load15 = os.getloadavg() +metrics["load"] = {"1m": load1, "5m": load5, "15m": load15, "cpu_count": cpu_count} +if load5 > cpu_count * load_warn_per_cpu: + add_alert(f"主机 5 分钟负载偏高:{load5:.2f} / CPU {cpu_count}") +else: + info.append(f"负载正常:5m={load5:.2f}, cpu={cpu_count}") + +meminfo = read_meminfo() +available_mb = meminfo.get("MemAvailable", 0) // 1024 +swap_total_mb = meminfo.get("SwapTotal", 0) // 1024 +swap_free_mb = meminfo.get("SwapFree", 0) // 1024 +swap_used_percent = 0 if swap_total_mb == 0 else round((swap_total_mb - swap_free_mb) * 100 / swap_total_mb, 2) +metrics["memory"] = { + "available_mb": available_mb, + "swap_total_mb": swap_total_mb, + "swap_free_mb": swap_free_mb, + "swap_used_percent": swap_used_percent, +} +if available_mb < mem_available_warn_mb: + add_alert(f"可用内存偏低:{available_mb}MiB") +else: + info.append(f"可用内存:{available_mb}MiB") +if swap_total_mb == 0: + add_alert("未启用 swap,内存抖动时可能导致 SSH/TLS 卡死") +elif swap_used_percent >= swap_used_warn_percent: + add_alert(f"swap 使用率偏高:{swap_used_percent}%") +else: + info.append(f"swap 正常:{swap_used_percent}% used") + +usage = shutil.disk_usage("/") +disk_used_percent = round(usage.used * 100 / usage.total, 2) +metrics["disk"] = {"root_used_percent": disk_used_percent, "root_free_gb": round(usage.free / 1024 / 1024 / 1024, 2)} +if disk_used_percent >= disk_warn_percent: + add_alert(f"根分区磁盘使用率偏高:{disk_used_percent}%") +else: + info.append(f"根分区磁盘:{disk_used_percent}% used") + + +def check_url(name, url, expected_version=None): + try: + with urllib.request.urlopen(url, timeout=8) as response: + body = response.read(4096).decode("utf-8", "replace") + code = response.status + metrics[f"{name}_status_code"] = code + if code >= 400: + add_alert(f"{name} 健康检查异常:HTTP {code}") + return + info.append(f"{name} 健康检查 OK:HTTP {code}") + if expected_version: + try: + payload = json.loads(body) + version = str(payload.get("version", "")) + metrics[f"{name}_version"] = version + if version != expected_version: + add_alert(f"{name} 版本不一致:当前 {version},期望 {expected_version}") + else: + info.append(f"{name} 版本 OK:{version}") + except json.JSONDecodeError: + add_alert(f"{name} 未返回 JSON,无法校验版本") + except Exception as error: + add_alert(f"{name} 健康检查失败:{error}") + + +check_url("API", api_health_url, expected_version or None) +check_url("Web", web_health_url) + +containers = [ + "xiaoxia-api-production", + "xiaoxia-worker-production", + "xiaoxia-web-production", + "xiaoxia-postgres-production", + "xiaoxia-redis-production", +] +container_results = {} +for name in containers: + result = run(["docker", "inspect", "-f", "{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{else}}no-health{{end}}", name]) + if result.returncode != 0: + add_alert(f"容器不存在或不可检查:{name}") + container_results[name] = "missing" + continue + state = result.stdout.strip() + container_results[name] = state + if not state.startswith("running"): + add_alert(f"容器未运行:{name} ({state})") + elif "unhealthy" in state: + add_alert(f"容器健康检查失败:{name} ({state})") + else: + info.append(f"容器正常:{name} ({state})") +metrics["containers"] = container_results + +status = "healthy" if not alerts else "warning" +report = { + "check_time": datetime.now(timezone.utc).astimezone().isoformat(), + "status": status, + "alerts": alerts, + "info": info, + "actions": actions, + "metrics": metrics, +} + +tmp_path = f"{report_path}.tmp.{int(time.time())}" +with open(tmp_path, "w", encoding="utf-8") as file: + json.dump(report, file, ensure_ascii=False, indent=2) +os.replace(tmp_path, report_path) +print(json.dumps(report, ensure_ascii=False, indent=2)) +PY diff --git a/tests/unit/test_production_resource_monitoring.py b/tests/unit/test_production_resource_monitoring.py new file mode 100644 index 000000000..53038161f --- /dev/null +++ b/tests/unit/test_production_resource_monitoring.py @@ -0,0 +1,46 @@ +from pathlib import Path + + +def test_production_resource_check_writes_duty_report(): + script = Path("scripts/production_resource_check.sh").read_text(encoding="utf-8") + + assert "REPORT_PATH=\"${REPORT_PATH:-/var/lib/xiaoxia-ci/duty_report.json}\"" in script + assert "API_HEALTH_URL=\"${API_HEALTH_URL:-http://127.0.0.1:8001/health}\"" in script + assert "WEB_HEALTH_URL=\"${WEB_HEALTH_URL:-http://127.0.0.1:3002/}\"" in script + assert "EXPECTED_VERSION" in script + assert "json.dump(report" in script + assert "os.replace(tmp_path, report_path)" in script + + +def test_production_resource_check_has_resource_thresholds(): + script = Path("scripts/production_resource_check.sh").read_text(encoding="utf-8") + + assert "LOAD_WARN_PER_CPU=\"${LOAD_WARN_PER_CPU:-1.5}\"" in script + assert "MEM_AVAILABLE_WARN_MB=\"${MEM_AVAILABLE_WARN_MB:-256}\"" in script + assert "DISK_WARN_PERCENT=\"${DISK_WARN_PERCENT:-85}\"" in script + assert "SWAP_USED_WARN_PERCENT=\"${SWAP_USED_WARN_PERCENT:-60}\"" in script + assert "未启用 swap" in script + assert "根分区磁盘使用率偏高" in script + + +def test_production_resource_check_tracks_core_containers(): + script = Path("scripts/production_resource_check.sh").read_text(encoding="utf-8") + + for container in [ + "xiaoxia-api-production", + "xiaoxia-worker-production", + "xiaoxia-web-production", + "xiaoxia-postgres-production", + "xiaoxia-redis-production", + ]: + assert container in script + + +def test_resource_monitoring_runbook_matches_heartbeat_contract(): + docs = Path("docs/PRODUCTION-RESOURCE-MONITORING.md").read_text(encoding="utf-8") + + assert "/var/lib/xiaoxia-ci/duty_report.json" in docs + assert "ssh xiaoxia-server" in docs + assert "EXPECTED_VERSION=v0.1.15" in docs + assert "*/5 * * * *" in docs + assert "需要老大确认后再启用" in docs