审计发现 5 处硬伤(/metrics 空壳、零备份、prod 双副本数据分裂、零审计、资源偏紧),
本轮全部落地。
=== P0-1 真实 /metrics(原来是空壳)===
原实现只返回一行注释 `# LokiStack Metrics`,注释却写着"可被 Prometheus 抓取",
平台挂了/DB 写不进去/告警引擎卡死都只能靠用户反馈。
现接入 prometheus/client_golang,四层指标:
HTTP : lokistack_http_requests_total / _duration_seconds / _in_flight_requests
存储 : lokistack_db_size_bytes / _db_save_duration_seconds / _db_save_total / _db_records
业务 : lokistack_deployments{status,mode} / lokistack_collectors{status,type,run_mode}
后台任务: lokistack_alert_evaluations_total / _duration_seconds / lokistack_loki_queries_total
+ go_* / process_*(GoCollector、ProcessCollector)
关键点:path 标签用 c.FullPath() 路由模板,不用原始 URL —— 否则 117 个 Deployment
会把 Prometheus 基数炸掉。指标定义下沉 pkg/metrics 叶子包,避开
services ↔ repository 循环依赖。
=== P0-2 备份与恢复(原来 0 处代码)===
平台全部状态在 /app/data/opsxloki.json(51KB)+ kubeconfigs/ + known_hosts/,
DB 丢了 = 失去"这些资源是谁建的"的映射,从此无法管理/卸载/升级在跑的 Loki。
CLI : lokistack backup --output <path> / restore <pkg> / self-check / version
API : GET /system/backup(下载)/ POST /system/restore(校验暂存)/
POST /system/restore/apply(确认应用)/ GET /system/backups
K8s : kubernetes/pdb-backup.yaml —— 每日 03:30 CronJob,保留最近 7 份
安全设计:
- manifest.json 记录 ENCRYPTION_KEY 的 sha256 前 16 位**指纹**,用于恢复时
校验"这把钥匙能不能解开这份备份",不泄露密钥本身
- 恢复分两步(校验暂存 → 确认应用),应用前自动把当前 DB 备份为
opsxloki.json.before-restore.<时间戳>
- 包内不含明文密钥(kubeconfig 本就是加密态)
=== P0-3 prod compose 双副本 → 单副本 ===
lokistack-1 / lokistack-2 各挂独立数据卷 + nginx 轮询 = 数据分裂:
页面刷新两次可能看到不同结果,写操作只落一个副本。不是高可用。
已改单实例(nginx 仅做反代),文件顶部与 nginx.conf 都写明原因。
=== P0-4 操作审计日志(借鉴 /usr/local/src/aiops 的字段口径)===
aiops 是每个 controller 各写 recordXxxAudit();本平台有 60+ 写接口,
逐个埋点必然漏,漏掉的那个恰好最危险(删集群、改存储凭证)。
改为 **gin 中间件统一记录** + handler 通过 SetAudit 补充业务语义:
models/audit.go : 字段对齐 aiops,另加 Method/Path/Before/After
services/audit.go : 写入 + 90 天/2 万条自动裁剪 + 脱敏 + diff 生成
middleware/audit.go : 自动识别 module/action/resource,跳过只读接口
controllers/audit.go: 列表/详情/统计(仅 admin)
敏感字段(password/secretKey/privateKey/token)写入前统一替换为 ***。
=== P1 稳定性 ===
- 平台资源 1C/1Gi → 4C/4Gi 限(500m/512Mi 请求):JSON DB 全量读写 +
每分钟告警评估 + 批量发布并发,1Gi 会 OOM
- terminationGracePeriodSeconds: 30 —— 收到 SIGTERM 要等 DB 完整落盘,
否则 kubelet SIGKILL 会把 JSON 写一半(全库丢失)
- liveness timeoutSeconds 10s(默认 1s 写盘时会被误杀)
- PDB minAvailable: 0 —— 让 kubectl drain 正确等待重建,不卡维护
- DB schemaVersion + migrateSchema() 版本递进迁移(原先加字段全靠零值兜底)
- scripts/release.sh —— 不可变 tag <版本>-<日期>-<commit> + IfNotPresent,
根治 latest 并发构建互相覆盖导致的静默回滚(本项目已实际踩过)
- PVC 保持 30Gi(按长期增长预留,云盘单价低,不做在线扩容)
=== P2 易用性 ===
- 一键自检 GET /system/self-check + 仪表盘自检卡:真实探活 Loki /ready、
集群凭据解密、磁盘空间、主机状态、近 24h 失败操作
- 审计日志页 /audit:统计卡 + 用户名/模块/结果筛选 + 变更前后 diff 弹窗
- 菜单收敛 13 → 7(页内 tab 切换,URL 全部不变):
资源接入 = 集群 + 主机 + 存储
日志服务 = Loki 实例 + 采集器
日志 = 查询 + 分析
系统管理 = 用户 + 审计 + 设置
新增 GroupTabs 组件 + meta.group/groupKey,侧栏按分组去重并正确高亮
测试:go vet 通过、go test 全绿、tsc 通过
234 lines
7.4 KiB
Go
234 lines
7.4 KiB
Go
package services
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"syscall"
|
||
"time"
|
||
|
||
"lokistack/models"
|
||
"lokistack/repository"
|
||
)
|
||
|
||
// CheckItem 单条自检结果
|
||
type CheckItem struct {
|
||
// Name 检查项名称(中文可读)
|
||
Name string `json:"name"`
|
||
// Status ok / warn / error
|
||
Status string `json:"status"`
|
||
// Message 一句话结论
|
||
Message string `json:"message,omitempty"`
|
||
// Detail 补充信息(数量、路径、错误原文)
|
||
Detail string `json:"detail,omitempty"`
|
||
}
|
||
|
||
// SelfCheckResult 一键诊断结果
|
||
type SelfCheckResult struct {
|
||
CheckedAt time.Time `json:"checkedAt"`
|
||
// Overall ok / warn / error —— 前端据此显示整体颜色
|
||
Overall string `json:"overall"`
|
||
Items []CheckItem `json:"items"`
|
||
}
|
||
|
||
// SelfCheck 平台自检:把「能不能用」的判断集中到一处。
|
||
//
|
||
// 设计目标:新人接手或故障时,先看这一个接口,不必在 5 个页面之间来回跳。
|
||
// 所有检查都必须有超时保护,不能让诊断本身把平台拖慢。
|
||
func SelfCheck() SelfCheckResult {
|
||
res := SelfCheckResult{CheckedAt: time.Now(), Overall: "ok"}
|
||
add := func(item CheckItem) {
|
||
res.Items = append(res.Items, item)
|
||
if item.Status == "error" {
|
||
res.Overall = "error"
|
||
} else if item.Status == "warn" && res.Overall == "ok" {
|
||
res.Overall = "warn"
|
||
}
|
||
}
|
||
|
||
db := repository.GetDB()
|
||
|
||
// 1) 数据库
|
||
if db == nil {
|
||
add(CheckItem{Name: "数据库", Status: "error", Message: "未初始化"})
|
||
} else {
|
||
dbPath := databasePath()
|
||
fi, err := os.Stat(dbPath)
|
||
if err != nil {
|
||
add(CheckItem{Name: "数据库", Status: "error", Message: "数据文件读不到", Detail: err.Error()})
|
||
} else {
|
||
db.RLock()
|
||
counts := fmt.Sprintf("集群 %d / 主机 %d / 存储 %d / 部署 %d / 采集器 %d / 审计 %d",
|
||
len(db.Clusters), len(db.Hosts), len(db.Storages), len(db.Deployments), len(db.Collectors), len(db.AuditLogs))
|
||
db.RUnlock()
|
||
status := "ok"
|
||
if fi.Size() > 16<<20 {
|
||
status = "warn"
|
||
}
|
||
add(CheckItem{Name: "数据库", Status: status,
|
||
Message: fmt.Sprintf("%.1f KB", float64(fi.Size())/1024), Detail: counts})
|
||
}
|
||
}
|
||
|
||
// 2) 加密密钥(丢了所有 kubeconfig/凭证都解不开)
|
||
if fp := encryptionKeyFingerprint(); fp == "" {
|
||
add(CheckItem{Name: "加密密钥", Status: "error",
|
||
Message: "ENCRYPTION_KEY 未配置",
|
||
Detail: "kubeconfig 与存储凭证依赖它解密;请确认 lokistack-secrets 中的 ENCRYPTION_KEY 已注入"})
|
||
} else {
|
||
add(CheckItem{Name: "加密密钥", Status: "ok", Message: "已配置", Detail: "指纹 " + fp})
|
||
}
|
||
|
||
// 3) 数据目录可用空间
|
||
if free, total, err := diskUsage(dataDirPath()); err == nil && total > 0 {
|
||
pct := float64(free) / float64(total) * 100
|
||
status, msg := "ok", fmt.Sprintf("剩余 %.1f GB(%.0f%%)", float64(free)/(1<<30), pct)
|
||
if pct < 10 {
|
||
status = "error"
|
||
} else if pct < 20 {
|
||
status = "warn"
|
||
}
|
||
add(CheckItem{Name: "数据盘", Status: status, Message: msg})
|
||
}
|
||
|
||
if db != nil {
|
||
db.RLock()
|
||
clusterList := db.Clusters
|
||
hostList := db.Hosts
|
||
deployList := db.Deployments
|
||
collectorList := db.Collectors
|
||
auditList := db.AuditLogs
|
||
db.RUnlock()
|
||
|
||
// 4) 集群接入(kubeconfig 能否解密)
|
||
if len(clusterList) == 0 {
|
||
add(CheckItem{Name: "K8s 集群", Status: "warn", Message: "尚未添加集群"})
|
||
} else {
|
||
bad := 0
|
||
for i := range clusterList {
|
||
if _, err := resolveKubeconfig(&clusterList[i]); err != nil {
|
||
bad++
|
||
}
|
||
}
|
||
if bad > 0 {
|
||
add(CheckItem{Name: "K8s 集群", Status: "error",
|
||
Message: fmt.Sprintf("%d/%d 个集群的 kubeconfig 无法解密", bad, len(clusterList)),
|
||
Detail: "多半是 ENCRYPTION_KEY 与写入时不一致"})
|
||
} else {
|
||
add(CheckItem{Name: "K8s 集群", Status: "ok", Message: fmt.Sprintf("%d 个,kubeconfig 均可解密", len(clusterList))})
|
||
}
|
||
}
|
||
|
||
// 5) 主机可达性(用最近一次验证结果,不在这里做 SSH 连接,避免诊断变慢)
|
||
if len(hostList) > 0 {
|
||
errCount := 0
|
||
for _, h := range hostList {
|
||
if h.Status == "error" {
|
||
errCount++
|
||
}
|
||
}
|
||
if errCount > 0 {
|
||
add(CheckItem{Name: "主机", Status: "warn",
|
||
Message: fmt.Sprintf("%d/%d 台连接异常", errCount, len(hostList)),
|
||
Detail: "到「主机管理」点「测试连接」查看具体原因"})
|
||
} else {
|
||
add(CheckItem{Name: "主机", Status: "ok", Message: fmt.Sprintf("%d 台均正常", len(hostList))})
|
||
}
|
||
}
|
||
|
||
// 6) Loki 部署实例(真实探活 /ready)
|
||
if len(deployList) == 0 {
|
||
add(CheckItem{Name: "Loki 实例", Status: "warn", Message: "尚未部署 Loki"})
|
||
} else {
|
||
var names []string
|
||
bad := 0
|
||
for _, d := range deployList {
|
||
status, err := probeDeploymentReady(&d)
|
||
if err != nil || !status {
|
||
bad++
|
||
names = append(names, d.Name)
|
||
}
|
||
}
|
||
if bad > 0 {
|
||
add(CheckItem{Name: "Loki 实例", Status: "error",
|
||
Message: fmt.Sprintf("%d/%d 个实例未就绪", bad, len(deployList)),
|
||
Detail: strings.Join(names, ", ")})
|
||
} else {
|
||
add(CheckItem{Name: "Loki 实例", Status: "ok", Message: fmt.Sprintf("%d 个全部 ready", len(deployList))})
|
||
}
|
||
}
|
||
|
||
// 7) 采集器
|
||
if len(collectorList) > 0 {
|
||
errCount, okCount := 0, 0
|
||
for _, c := range collectorList {
|
||
switch c.Status {
|
||
case "active", "running":
|
||
okCount++
|
||
case "error":
|
||
errCount++
|
||
}
|
||
}
|
||
if errCount > 0 {
|
||
add(CheckItem{Name: "采集器", Status: "error",
|
||
Message: fmt.Sprintf("%d 个异常", errCount),
|
||
Detail: fmt.Sprintf("正常 %d 个;到「采集器」页看具体报错", okCount)})
|
||
} else {
|
||
add(CheckItem{Name: "采集器", Status: "ok", Message: fmt.Sprintf("%d 个正常", okCount)})
|
||
}
|
||
}
|
||
|
||
// 8) 近期失败操作(审计)
|
||
recentFail := 0
|
||
cutoff := time.Now().Add(-24 * time.Hour)
|
||
for _, a := range auditList {
|
||
if a.CreatedAt.After(cutoff) && a.Status >= 400 {
|
||
recentFail++
|
||
}
|
||
}
|
||
if recentFail > 0 {
|
||
add(CheckItem{Name: "近 24h 失败操作", Status: "warn",
|
||
Message: fmt.Sprintf("%d 次", recentFail), Detail: "到「审计日志」筛选失败查看详情"})
|
||
} else {
|
||
add(CheckItem{Name: "近 24h 失败操作", Status: "ok", Message: "无"})
|
||
}
|
||
}
|
||
|
||
return res
|
||
}
|
||
|
||
// probeDeploymentReady 探测某个 Loki 部署实例是否 /ready。
|
||
//
|
||
// 不用 DeploymentReady():那个函数按"部署名 == 数据源名"匹配,
|
||
// 而生成的 K8s 资源名固定是 loki,两者对不上会永远返回未就绪(已知问题)。
|
||
// 这里直接按部署记录里的 endpoint 探活,并对主机模式修正监听地址:
|
||
// 主机 Docker 部署存的是 0.0.0.0:3100,从平台 Pod 侧要用主机真实 IP。
|
||
func probeDeploymentReady(d *models.LokiDeployment) (bool, error) {
|
||
url := strings.TrimSuffix(strings.TrimSpace(d.Endpoint), "/")
|
||
if url == "" {
|
||
return false, fmt.Errorf("部署未记录访问地址")
|
||
}
|
||
if strings.Contains(url, "0.0.0.0") && d.Host != nil && d.Host.HostIP != "" {
|
||
url = strings.Replace(url, "0.0.0.0", d.Host.HostIP, 1)
|
||
}
|
||
client := &http.Client{Timeout: 3 * time.Second}
|
||
resp, err := client.Get(url + "/ready")
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer resp.Body.Close()
|
||
buf := make([]byte, 128)
|
||
n, _ := resp.Body.Read(buf)
|
||
return resp.StatusCode == 200 && strings.Contains(strings.ToLower(string(buf[:n])), "ready"), nil
|
||
}
|
||
|
||
// diskUsage 返回剩余与总空间(字节)
|
||
func diskUsage(path string) (free, total uint64, err error) {
|
||
var st syscall.Statfs_t
|
||
if err := syscall.Statfs(path, &st); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize), nil
|
||
}
|