chore(ops): add production resource monitoring check

This commit is contained in:
Xiaoxia AI
2026-06-23 08:27:04 +08:00
parent 816d98175a
commit e1764b1332
3 changed files with 344 additions and 0 deletions
+136
View File
@@ -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。
+162
View File
@@ -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} 健康检查 OKHTTP {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
@@ -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