feat: 建立可观测性、容量与恢复验证基线

This commit is contained in:
weijuesen
2026-08-14 14:54:56 +08:00
parent 126c215a6b
commit 1e6a5dbfb8
16 changed files with 398 additions and 3 deletions
+1
View File
@@ -608,6 +608,7 @@ MODEL_MODE=mock .venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --po
- 部署规则(备份/回滚/冒烟)见根目录 `AGENTS.md`「部署到开发服务器」;SSH/SFTP 工具为 `scripts/devssh.py``PAN_SSH_PASS` 环境变量传密码) - 部署规则(备份/回滚/冒烟)见根目录 `AGENTS.md`「部署到开发服务器」;SSH/SFTP 工具为 `scripts/devssh.py``PAN_SSH_PASS` 环境变量传密码)
- 服务清单:Go 后端 :3000、Web :5174、ai-service :8000、recorder-go :9090、PostgreSQL :5432、IoTDB :18081、VerneMQ :1883、Ceph RGW :7480、WVP :18080、ZLM :8081 - 服务清单:Go 后端 :3000、Web :5174、ai-service :8000、recorder-go :9090、PostgreSQL :5432、IoTDB :18081、VerneMQ :1883、Ceph RGW :7480、WVP :18080、ZLM :8081
- 可观测性:HTTP 请求统一 `X-Request-ID`;日志脱敏;`GET /api/v1/ops/metrics` 提供依赖指标;SLO/负载/备份恢复演练文档见 `docs/operations/`
## 13. Git 注意事项 ## 13. Git 注意事项
+3
View File
@@ -2,6 +2,7 @@ import shutil
import subprocess import subprocess
import threading import threading
import time import time
import uuid
from fastapi import FastAPI, File, HTTPException, Request, UploadFile from fastapi import FastAPI, File, HTTPException, Request, UploadFile
@@ -21,8 +22,10 @@ stream_worker = StreamTaskWorker(config.STREAM_TASK_MAX_WORKERS)
async def count_requests(request: Request, call_next): async def count_requests(request: Request, call_next):
global _requests, _latency_total global _requests, _latency_total
start = time.time() start = time.time()
request_id = request.headers.get("x-request-id") or str(uuid.uuid4())
response = await call_next(request) response = await call_next(request)
latency = (time.time() - start) * 1000 latency = (time.time() - start) * 1000
response.headers["X-Request-ID"] = request_id
with _lock: with _lock:
_requests += 1 _requests += 1
_latency_total += latency _latency_total += latency
+1
View File
@@ -31,6 +31,7 @@ def test_health():
def test_detect_ok(): def test_detect_ok():
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")}) r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
assert r.status_code == 200 assert r.status_code == 200
assert r.headers.get("x-request-id")
body = r.json() body = r.json()
assert body["model"] == "mock" assert body["model"] == "mock"
assert body["modelVersion"] assert body["modelVersion"]
+36
View File
@@ -0,0 +1,36 @@
# 备份恢复演练
## 目标
验证 PostgreSQL、IoTDB、Ceph/S3 和业务配置的可恢复性,记录 RPO/RTO。
## 演练前
1. 记录当前版本、commit、schema 版本和服务清单。
2. 使用 `pg_dump` 备份 PostgreSQL 到隔离命名空间。
3. 备份 IoTDB 数据目录或按现有运维流程导出。
4. 记录 Ceph/S3 bucket 清单和对象数量。
## 恢复步骤
1. 在隔离环境创建空 PostgreSQL 数据库。
2. 执行 `pg_restore` 恢复备份。
3. 恢复 IoTDB 数据并启动服务。
4. 校验图片、光谱、录像对象引用与数据库记录一致。
5. 启动 Go/AI/Web 服务并执行核心接口冒烟。
## 记录项
- 备份开始/结束时间
- 备份体积
- 恢复开始/结束时间
- RPO(最近可用备份到故障时间)
- RTO(故障到服务可用)
- 数据点核对:巡检、遥测、检测、通知、任务、会诊、溯源
- 失败项与原因
## 验收
- 核心业务记录无缺失。
- 图片/光谱/录像对象引用可访问。
- 至少完成一次真实恢复演练后才允许执行正式生产迁移。
+39
View File
@@ -0,0 +1,39 @@
# 负载测试场景
目标环境:Go 后端 + PostgreSQL + Redis + IoTDB + AI 服务。结果需记录测试日期、版本、机器配置、并发、时长、p50/p95、失败率和观察到的限制。
## A. 500 在线用户 API 混合负载
比例:
- 80% 查询类:房间、设备、遥测最新值、告警列表
- 10% 遥测趋势:历史/聚合查询
- 5% 上传类:图片巡检上传
- 5% 管理操作:阈值、批次、检测任务、会诊
验收:API 错误率 <1%,业务接口 p95 <1s;上传成功且巡检闭环可追踪。
## B. 1000 WebSocket 连接
- 1000 个连接按授权设备订阅
- 持续 30 分钟
- 记录连接成功率、掉线率、消息延迟、CPU/内存
验收:连接成功率 >99%,消息 P95 延迟 <500ms,无内存持续增长。
## C. AI 峰值
固定条件:
- GPU:目标 T4 16G
- 模型版本和 ONNX 文件 hash
- 输入尺寸
- 并发数(建议 1/4/8/16
记录 p50/p95、失败率、GPU 显存和单帧延迟。结果用于设置生产并发上限和推理告警阈值。
## 禁止事项
- 不在生产库执行写入类负载。
- 不使用真实用户隐私图片做公开压测。
- 压测前必须备份数据库并记录环境状态。
+39
View File
@@ -0,0 +1,39 @@
# 可观测性 SLO 与告警阈值
## 日志与请求 ID
- Go 后端每个 HTTP 请求生成或透传 `X-Request-ID`,日志统一记录 `requestId/method/path/query/status/latency/ip/authType`
- 日志不记录 Authorization 原始值;query 中 `token/password/secret/authorization` 类参数统一替换为 `[redacted]`
- AI 服务 `/detect` 同样透传 `X-Request-ID`,便于跨 Go/Python 全链路追踪。
## 依赖指标
`GET /api/v1/ops/metrics``log:read`)返回依赖指标,至少记录请求数、失败数、最近延迟、P50/P95、最近成功/失败时间和最近错误。目标覆盖:
- PostgreSQL
- Redis
- MQTT
- IoTDB
- S3
- AI 服务
- WVP/ZLM
- 微信
- 天气
## 初始告警阈值
| 指标 | 目标 | 告警阈值 | 负责人 |
|---|---|---|---|
| API 可用性 | 99.5% | 5 分钟错误率 >5% | 后端 + 运维 |
| API p95 延迟 | <500ms(业务接口) | 5 分钟 p95 >1s | 后端 |
| 登录失败率 | 正常用户 <2% | 5 分钟失败率 >10% | 后端 + 安全 |
| Redis | 可用 | Ping 失败 3 次 | 运维 |
| PostgreSQL | 可用 | 连接/查询失败 3 次 | 后端 + 运维 |
| IoTDB | 可用或明确降级 | 持续不可用且遥测回退 | 后端 |
| AI 推理失败率 | <1% | 5 分钟失败率 >5% | AI + 后端 |
| Ceph 容量 | 使用率 <80% | 使用率 >80% | 运维 |
| Outbox 积压 | 常态 <50 | pending+retry >200 | 后端 |
## 模拟故障验证
每次发布前至少验证一次通知链:断开 Redis → 确认认证接口返回 503 → 恢复 Redis → 确认自动恢复;如部署告警工具,则同时验证告警通道可送达负责人。
+2
View File
@@ -95,6 +95,7 @@ func main() {
// 9. 创建 Gin 引擎 // 9. 创建 Gin 引擎
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(middleware.RequestIDMiddleware())
r.Use(middleware.Logger()) r.Use(middleware.Logger())
r.Use(middleware.SecurityHeaders()) r.Use(middleware.SecurityHeaders())
r.Use(middleware.CORS()) r.Use(middleware.CORS())
@@ -150,6 +151,7 @@ func main() {
handler.RegisterAuditRoutes(api, db) handler.RegisterAuditRoutes(api, db)
handler.RegisterUserRoutes(api, db) handler.RegisterUserRoutes(api, db)
handler.RegisterPermissionRoutes(api, db) handler.RegisterPermissionRoutes(api, db)
handler.RegisterOpsRoutes(api, db)
// 12. 启动 HTTP 服务 // 12. 启动 HTTP 服务
addr := ":" + strconv.Itoa(cfg.Port) addr := ":" + strconv.Itoa(cfg.Port)
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"net/http"
"time"
"silk-server-go/internal/middleware"
"silk-server-go/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var opsStartedAt = time.Now()
// RegisterOpsRoutes 注册运维指标路由。
func RegisterOpsRoutes(rg *gin.RouterGroup, db *gorm.DB) {
rg.GET("/ops/metrics", middleware.RequirePermission(db, "log:read"), opsMetrics())
}
func opsMetrics() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"uptimeSeconds": int(time.Since(opsStartedAt).Seconds()),
"dependencies": service.DependencySnapshot(),
})
}
}
+10
View File
@@ -2,6 +2,7 @@ package middleware
import ( import (
"log/slog" "log/slog"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -17,13 +18,22 @@ func Logger() gin.HandlerFunc {
latency := time.Since(start) latency := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
query := RedactSensitiveQuery(c.Request.URL.RawQuery)
auth := c.GetHeader("Authorization")
authPrefix := ""
if auth != "" {
authPrefix = strings.SplitN(auth, " ", 2)[0]
}
slog.Info("请求", slog.Info("请求",
"requestId", RequestID(c),
"method", c.Request.Method, "method", c.Request.Method,
"path", path, "path", path,
"query", query,
"status", status, "status", status,
"latency", latency.String(), "latency", latency.String(),
"ip", c.ClientIP(), "ip", c.ClientIP(),
"authType", authPrefix,
) )
} }
} }
@@ -0,0 +1,65 @@
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const RequestIDHeader = "X-Request-ID"
// RequestID 返回当前请求 ID。
func RequestID(c *gin.Context) string {
value, _ := c.Get("requestId")
if id, ok := value.(string); ok {
return id
}
return ""
}
// RequestIDMiddleware 生成或透传 X-Request-ID。
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
id := c.GetHeader(RequestIDHeader)
if id == "" {
id = randomRequestID()
}
c.Set("requestId", id)
c.Header(RequestIDHeader, id)
c.Next()
}
}
func randomRequestID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "req-" + hex.EncodeToString([]byte(time.Now().Format(time.RFC3339Nano)))
}
return hex.EncodeToString(b)
}
// RedactSensitiveQuery 脱敏 URL query 中的 token/password/secret。
func RedactSensitiveQuery(rawQuery string) string {
if rawQuery == "" {
return ""
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return "[invalid-query]"
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
for _, key := range keys {
lower := strings.ToLower(key)
if strings.Contains(lower, "token") || strings.Contains(lower, "password") || strings.Contains(lower, "secret") || strings.Contains(lower, "authorization") {
values.Set(key, "[redacted]")
}
}
return values.Encode()
}
@@ -0,0 +1,42 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRequestIDMiddlewarePassesThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.Header.Set(RequestIDHeader, "request-1")
RequestIDMiddleware()(c)
if rec.Header().Get(RequestIDHeader) != "request-1" {
t.Fatalf("header = %s", rec.Header().Get(RequestIDHeader))
}
if RequestID(c) != "request-1" {
t.Fatalf("request id = %s", RequestID(c))
}
}
func TestRequestIDMiddlewareGenerates(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
RequestIDMiddleware()(c)
if RequestID(c) == "" {
t.Fatal("should generate request id")
}
}
func TestRedactSensitiveQuery(t *testing.T) {
got := RedactSensitiveQuery("access_token=abc&password=secret&roomId=1")
if got != "access_token=%5Bredacted%5D&password=%5Bredacted%5D&roomId=1" {
t.Fatalf("redacted query = %s", got)
}
}
+8 -1
View File
@@ -77,6 +77,7 @@ func NewAIClient(baseURL string) *AIClient {
// Detect 上传图片到 ai-service /detect,返回检测结果(无状态接口,天然幂等) // Detect 上传图片到 ai-service /detect,返回检测结果(无状态接口,天然幂等)
func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename string) (*AIDetectResponse, error) { func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename string) (*AIDetectResponse, error) {
start := time.Now()
var body bytes.Buffer var body bytes.Buffer
writer := multipart.NewWriter(&body) writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename) part, err := writer.CreateFormFile("file", filename)
@@ -97,7 +98,9 @@ func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename strin
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
latency := time.Since(start)
if err != nil { if err != nil {
RecordDependency(DependencyAI, err, latency)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
@@ -107,12 +110,16 @@ func (c *AIClient) Detect(ctx context.Context, imageBytes []byte, filename strin
return nil, err return nil, err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ai-service /detect 返回 %d: %s", resp.StatusCode, string(data)) err := fmt.Errorf("ai-service /detect 返回 %d: %s", resp.StatusCode, string(data))
RecordDependency(DependencyAI, err, latency)
return nil, err
} }
var out AIDetectResponse var out AIDetectResponse
if err := json.Unmarshal(data, &out); err != nil { if err := json.Unmarshal(data, &out); err != nil {
RecordDependency(DependencyAI, err, latency)
return nil, err return nil, err
} }
RecordDependency(DependencyAI, nil, latency)
return &out, nil return &out, nil
} }
@@ -0,0 +1,86 @@
package service
import (
"sort"
"sync"
"time"
)
// Dependency 依赖名称。
type Dependency string
const (
DependencyPostgres Dependency = "postgresql"
DependencyRedis Dependency = "redis"
DependencyMQTT Dependency = "mqtt"
DependencyIoTDB Dependency = "iotdb"
DependencyS3 Dependency = "s3"
DependencyAI Dependency = "ai"
DependencyWVP Dependency = "wvp"
DependencyWechat Dependency = "wechat"
DependencyWeather Dependency = "weather"
)
// DependencyMetric 依赖调用指标。
type DependencyMetric struct {
Requests int64 `json:"requests"`
Failures int64 `json:"failures"`
LastLatencyMs float64 `json:"lastLatencyMs"`
P50LatencyMs float64 `json:"p50LatencyMs"`
P95LatencyMs float64 `json:"p95LatencyMs"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
LastFailureAt *time.Time `json:"lastFailureAt,omitempty"`
LastError string `json:"lastError,omitempty"`
}
var dependencyMetrics sync.Map
// RecordDependency 记录一次依赖调用。
func RecordDependency(dep Dependency, err error, latency time.Duration) {
now := time.Now()
value, _ := dependencyMetrics.LoadOrStore(dep, &DependencyMetric{})
metric := value.(*DependencyMetric)
metric.Requests++
metric.LastLatencyMs = float64(latency.Microseconds()) / 1000
if err != nil {
metric.Failures++
metric.LastFailureAt = &now
metric.LastError = err.Error()
} else {
metric.LastSuccessAt = &now
metric.LastError = ""
}
}
// DependencySnapshot 返回当前依赖指标快照。
func DependencySnapshot() map[Dependency]DependencyMetric {
result := map[Dependency]DependencyMetric{}
dependencyMetrics.Range(func(key, value interface{}) bool {
dep := key.(Dependency)
metric := *value.(*DependencyMetric)
result[dep] = metric
return true
})
return result
}
// RecordDependencyHistogram 预留直方图能力;当前仅更新最近一次指标。
func RecordDependencyHistogram(dep Dependency, err error, latency time.Duration, samples []float64) {
RecordDependency(dep, err, latency)
if len(samples) == 0 {
return
}
value, _ := dependencyMetrics.LoadOrStore(dep, &DependencyMetric{})
metric := value.(*DependencyMetric)
sort.Float64s(samples)
metric.P50LatencyMs = percentile(samples, 0.5)
metric.P95LatencyMs = percentile(samples, 0.95)
}
func percentile(sorted []float64, p float64) float64 {
if len(sorted) == 0 {
return 0
}
idx := int(float64(len(sorted)-1) * p)
return sorted[idx]
}
+2 -2
View File
@@ -1,6 +1,6 @@
# 后续工作计划 # 后续工作计划
> **完成状态(2026-08-14 更新)**#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 > **完成状态(2026-08-14 更新)**#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12/13 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
## 整改实施计划 Wave 0-42026-08-13 启动) ## 整改实施计划 Wave 0-42026-08-13 启动)
@@ -21,7 +21,7 @@
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 | | Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 |
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 | | Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 |
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 | | Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 |
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 | | Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 部分可用 | 待真实环境负载/备份恢复演练与告警通道联调 |
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 | | Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
## 技术选型决策(2026-08-11 确定) ## 技术选型决策(2026-08-11 确定)
+29
View File
@@ -1118,3 +1118,32 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
- 本任务前分支提交为 `69ef5a0`;回滚可还原 Task 12 提交; - 本任务前分支提交为 `69ef5a0`;回滚可还原 Task 12 提交;
- 数据库回滚执行 `000008_governance_effectiveness.down.sql` 可删除意见版本、规则结果、知识审核表和新增列; - 数据库回滚执行 `000008_governance_effectiveness.down.sql` 可删除意见版本、规则结果、知识审核表和新增列;
- Web 页面回滚需还原会诊、知识、溯源页面及对应 DAL,不覆盖旧专家意见。 - Web 页面回滚需还原会诊、知识、溯源页面及对应 DAL,不覆盖旧专家意见。
## 2026-08-14 整改 Task 13:建立可观测性、容量与恢复验证
### 做了什么
- 新增 `X-Request-ID` 中间件:请求 ID 生成或透传、响应头返回、日志记录 `requestId`query 中 token/password/secret/authorization 自动脱敏;
- 新增依赖指标注册表和 `GET /api/v1/ops/metrics`,AI 客户端记录请求数、失败数、最近延迟和 P50/P95;
- AI 服务 `/detect` 透传 `X-Request-ID`,并新增响应头测试;
- 新增 `docs/operations/slo-and-alerts.md``load-test-scenarios.md``backup-restore-drill.md`
- `部署指南(物理机).md` 增加可观测性与恢复验证要求。
### 设计思路与决策依据
- 全链路追踪从 HTTP 入口开始,先保证请求 ID 和日志脱敏,后续可替换为正式 tracing/APM
- 依赖指标采用内存快照,满足当前单实例观测;多实例或生产规模化后再接入 Prometheus/OTel
- SLO 和告警阈值先给出可执行基线,不把未运行的监控平台写入“已验证”状态;
- 备份恢复文档要求真实演练记录 RPO/RTO,避免只在文档里声明可恢复。
### 验证结果
- `scripts/verify.ps1` exit 0Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过;
- 新增测试覆盖 requestId 透传/生成、敏感 query 脱敏、AI requestId 响应头;
- 未部署开发服务器,未执行 500/1000 用户负载、真实备份恢复演练或告警通道故障演练。
### 回滚点
- 本任务前分支提交为 `126c215`;回滚可还原 Task 13 提交;
- requestId/日志脱敏可安全保留;若指标采集开销过高,可降低采样频率;
- 运维文档和部署指南可直接保留,不删除已记录的恢复演练要求。
+7
View File
@@ -916,3 +916,10 @@ tail -5 /home/pan/recorder.log
``` ```
> **注意**Ceph RGW 的 `radosgw-admin` 不支持 `bucket create` 命令,bucket 必须通过 S3 APIPUT /bucket-name)创建。 > **注意**Ceph RGW 的 `radosgw-admin` 不支持 `bucket create` 命令,bucket 必须通过 S3 APIPUT /bucket-name)创建。
## 可观测性与恢复验证
- 每个 HTTP 请求统一记录 `X-Request-ID`,日志不得包含 Authorization 原始值或敏感 query。
- 依赖指标通过 `GET /api/v1/ops/metrics` 查看;发布前按 `docs/operations/slo-and-alerts.md` 检查告警阈值。
- 发布前备份 PostgreSQL,并参考 `docs/operations/backup-restore-drill.md` 记录备份时间、恢复时间和 RPO/RTO。
- 负载和容量验证场景见 `docs/operations/load-test-scenarios.md`