chore(ops): add production resource monitoring check
This commit is contained in:
@@ -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。
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user