审计发现 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 通过
439 lines
12 KiB
Go
439 lines
12 KiB
Go
package repository
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
|
||
"lokistack/config"
|
||
"lokistack/models"
|
||
"lokistack/pkg/metrics"
|
||
)
|
||
|
||
// saveRequest 表示一个保存请求,通过channel序列化
|
||
type saveRequest struct {
|
||
done chan error // 用于同步等待保存完成
|
||
}
|
||
|
||
// JsonDB 使用基于channel的序列化保存机制,防止并发数据丢失。
|
||
// 该包只负责持久化(落盘 + 并发安全),领域实体定义在 models 包。
|
||
type JsonDB struct {
|
||
mu sync.RWMutex
|
||
dataFile string
|
||
Users []models.User `json:"users"`
|
||
Clusters []models.Cluster `json:"clusters"`
|
||
Hosts []models.Host `json:"hosts"`
|
||
Storages []models.StorageConfig `json:"storages"`
|
||
Deployments []models.LokiDeployment `json:"deployments"`
|
||
Collectors []models.Collector `json:"collectors"`
|
||
Logs []models.DeploymentLog `json:"logs"`
|
||
Settings []models.SystemSetting `json:"settings"`
|
||
LogSources []models.LogSource `json:"logSources"`
|
||
AlertRules []models.AlertRule `json:"alertRules"`
|
||
AlertDeliveries []models.AlertDelivery `json:"alertDeliveries"`
|
||
NotifyChannels []models.NotifyChannel `json:"notifyChannels"`
|
||
LogPolicyGroups []models.LogPolicyGroup `json:"logPolicyGroups"`
|
||
// AuditLogs 操作审计日志(保留期 90 天 / 上限 2 万条,见 services.auditRetention)
|
||
AuditLogs []models.AuditLog `json:"auditLogs"`
|
||
|
||
// SchemaVersion 数据结构版本号。
|
||
// 之前加字段全靠 JSON 反序列化的零值兜底,字段一多必然出事
|
||
// (例如新增 ScrapeJobs 时老数据要手写兼容)。有了版本号,
|
||
// 以后的结构变更可以在 InitDB 里按版本号顺序迁移,可测、可回滚。
|
||
SchemaVersion int `json:"schemaVersion"`
|
||
sequences map[string]uint
|
||
|
||
// 防抖保存机制
|
||
saveTimer *time.Timer
|
||
saveMu sync.Mutex
|
||
saveChan chan saveRequest
|
||
stopChan chan struct{}
|
||
wg sync.WaitGroup
|
||
}
|
||
|
||
var db *JsonDB
|
||
|
||
func GetDB() *JsonDB {
|
||
return db
|
||
}
|
||
|
||
// CurrentSchemaVersion 当前数据结构版本。
|
||
// 每次做**不兼容**的结构变更时 +1,并在 migrateSchema 里补一段迁移逻辑。
|
||
//
|
||
// v0/v1(无字段)→ 1:新增 auditLogs / schemaVersion(老库走零值兜底即可,无需处理)
|
||
// v1 → 2:预留示例(如把 ScrapeJobs 从 logPath 拆分时,需按旧结构生成默认 job)
|
||
const CurrentSchemaVersion = 1
|
||
|
||
// migrateSchema 按版本号顺序迁移老库。返回是否发生了迁移(需要回写)。
|
||
func (d *JsonDB) migrateSchema() bool {
|
||
if d.SchemaVersion >= CurrentSchemaVersion {
|
||
return false
|
||
}
|
||
from := d.SchemaVersion
|
||
|
||
// 逐版本递进,每段只处理"上一版 → 下一版"的差异,
|
||
// 不要写成 if version < X 的叠加判断,否则分支会指数增长。
|
||
for d.SchemaVersion < CurrentSchemaVersion {
|
||
switch d.SchemaVersion {
|
||
case 0:
|
||
// v0 → v1:补审计表;老库没有 auditLogs 字段,反序列化后为 nil,
|
||
// 这里显式初始化,避免后续 append 到 nil 之外还留着 nil 判断。
|
||
if d.AuditLogs == nil {
|
||
d.AuditLogs = []models.AuditLog{}
|
||
}
|
||
d.SchemaVersion = 1
|
||
default:
|
||
// 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏
|
||
log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion)
|
||
return false
|
||
}
|
||
}
|
||
log.Printf("[schema] 数据结构已从 v%d 迁移到 v%d", from, d.SchemaVersion)
|
||
return true
|
||
}
|
||
|
||
func InitDB() error {
|
||
cfg := config.GetConfig()
|
||
_ = os.MkdirAll(cfg.DataDir, 0700)
|
||
|
||
d := &JsonDB{
|
||
dataFile: cfg.DatabasePath,
|
||
Users: []models.User{},
|
||
Clusters: []models.Cluster{},
|
||
Hosts: []models.Host{},
|
||
Storages: []models.StorageConfig{},
|
||
Deployments: []models.LokiDeployment{},
|
||
Collectors: []models.Collector{},
|
||
Logs: []models.DeploymentLog{},
|
||
Settings: []models.SystemSetting{},
|
||
LogSources: []models.LogSource{},
|
||
AlertRules: []models.AlertRule{},
|
||
AlertDeliveries: []models.AlertDelivery{},
|
||
NotifyChannels: []models.NotifyChannel{},
|
||
LogPolicyGroups: []models.LogPolicyGroup{},
|
||
AuditLogs: []models.AuditLog{},
|
||
SchemaVersion: CurrentSchemaVersion,
|
||
sequences: map[string]uint{
|
||
"users": 0,
|
||
"clusters": 0,
|
||
"hosts": 0,
|
||
"storages": 0,
|
||
"deployments": 0,
|
||
"collectors": 0,
|
||
"logs": 0,
|
||
"logPolicyGroups": 0,
|
||
},
|
||
saveChan: make(chan saveRequest, 10),
|
||
stopChan: make(chan struct{}),
|
||
}
|
||
|
||
if data, err := os.ReadFile(d.dataFile); err == nil {
|
||
if err := json.Unmarshal(data, d); err == nil {
|
||
d.sequences = map[string]uint{}
|
||
for _, u := range d.Users {
|
||
if u.ID > d.sequences["users"] {
|
||
d.sequences["users"] = u.ID
|
||
}
|
||
}
|
||
for _, c := range d.Clusters {
|
||
if c.ID > d.sequences["clusters"] {
|
||
d.sequences["clusters"] = c.ID
|
||
}
|
||
}
|
||
for _, h := range d.Hosts {
|
||
if h.ID > d.sequences["hosts"] {
|
||
d.sequences["hosts"] = h.ID
|
||
}
|
||
}
|
||
for _, s := range d.Storages {
|
||
if s.ID > d.sequences["storages"] {
|
||
d.sequences["storages"] = s.ID
|
||
}
|
||
}
|
||
for _, de := range d.Deployments {
|
||
if de.ID > d.sequences["deployments"] {
|
||
d.sequences["deployments"] = de.ID
|
||
}
|
||
}
|
||
for _, col := range d.Collectors {
|
||
if col.ID > d.sequences["collectors"] {
|
||
d.sequences["collectors"] = col.ID
|
||
}
|
||
}
|
||
for _, l := range d.Logs {
|
||
if l.ID > d.sequences["logs"] {
|
||
d.sequences["logs"] = l.ID
|
||
}
|
||
}
|
||
for _, group := range d.LogPolicyGroups {
|
||
if group.ID > d.sequences["logPolicyGroups"] {
|
||
d.sequences["logPolicyGroups"] = group.ID
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
migratedLogAccess := migrateLogAccessToGroups(d)
|
||
// 结构版本迁移(审计表、采集项等新字段的兼容处理)
|
||
migratedSchema := d.migrateSchema()
|
||
// 启动序列化保存goroutine
|
||
d.wg.Add(1)
|
||
go d.saveWorker()
|
||
|
||
db = d
|
||
if migratedLogAccess || migratedSchema {
|
||
d.RequestSave()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Legacy per-user grants are grouped by identical source and label scope.
|
||
func migrateLogAccessToGroups(d *JsonDB) bool {
|
||
changed := false
|
||
for i := range d.LogSources {
|
||
source := &d.LogSources[i]
|
||
if len(source.Access) == 0 {
|
||
continue
|
||
}
|
||
byLabels := map[string]*models.LogPolicyGroup{}
|
||
for _, rule := range source.Access {
|
||
validUser := false
|
||
for _, user := range d.Users {
|
||
if user.ID == rule.UserID && user.Role != "admin" {
|
||
validUser = true
|
||
break
|
||
}
|
||
}
|
||
if !validUser {
|
||
continue
|
||
}
|
||
alreadyGrouped := false
|
||
for _, group := range d.LogPolicyGroups {
|
||
if group.SourceID != source.ID {
|
||
continue
|
||
}
|
||
for _, id := range group.UserIDs {
|
||
if id == rule.UserID {
|
||
alreadyGrouped = true
|
||
break
|
||
}
|
||
}
|
||
}
|
||
if alreadyGrouped {
|
||
continue
|
||
}
|
||
keyBytes, _ := json.Marshal(rule.Labels)
|
||
key := string(keyBytes)
|
||
group := byLabels[key]
|
||
if group == nil {
|
||
d.sequences["logPolicyGroups"]++
|
||
id := d.sequences["logPolicyGroups"]
|
||
group = &models.LogPolicyGroup{ID: id, Name: fmt.Sprintf("旧授权 · %s · %d", source.Name, id), SourceID: source.ID, Labels: rule.Labels, CreatedAt: time.Now(), UpdatedAt: time.Now()}
|
||
byLabels[key] = group
|
||
}
|
||
group.UserIDs = append(group.UserIDs, rule.UserID)
|
||
}
|
||
for _, group := range byLabels {
|
||
d.LogPolicyGroups = append(d.LogPolicyGroups, *group)
|
||
}
|
||
source.Access = nil
|
||
changed = true
|
||
}
|
||
return changed
|
||
}
|
||
|
||
// Shutdown 优雅关闭数据库,确保所有数据已落盘。
|
||
// 顺序很关键:先停防抖定时器(不再产生新请求)→ 关闭 worker 并等其退出
|
||
// (worker 可能正在处理已入队的请求)→ 此时已无并发,做最后一次 doSave 把
|
||
// 最新内存状态原子写入。注意绝不能在 worker 仍在运行时直接 Save(),否则
|
||
// 两个 doSave 并发改写同一文件会发生"后写覆盖先写"的丢更新。
|
||
func Shutdown() error {
|
||
if db == nil {
|
||
return nil
|
||
}
|
||
|
||
// 1. 取消防抖定时器,阻止新的保存请求进入 channel
|
||
db.saveMu.Lock()
|
||
if db.saveTimer != nil {
|
||
db.saveTimer.Stop()
|
||
db.saveTimer = nil
|
||
}
|
||
db.saveMu.Unlock()
|
||
|
||
// 2. 通知 worker 停止并等待其排空 saveChan、完成进行中的 doSave 后退出
|
||
close(db.stopChan)
|
||
db.wg.Wait()
|
||
|
||
// 3. worker 已退出,无并发,落盘最终状态
|
||
if err := db.doSave(); err != nil {
|
||
return fmt.Errorf("final save failed: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// saveWorker 通过channel序列化所有保存操作,防止并发写入冲突
|
||
func (d *JsonDB) saveWorker() {
|
||
defer d.wg.Done()
|
||
for {
|
||
select {
|
||
case req, ok := <-d.saveChan:
|
||
if !ok {
|
||
return
|
||
}
|
||
err := d.doSave()
|
||
if req.done != nil {
|
||
req.done <- err
|
||
}
|
||
case <-d.stopChan:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// doSave 执行实际的原子文件写入
|
||
func (d *JsonDB) doSave() error {
|
||
start := time.Now()
|
||
err := d.doSaveInner()
|
||
// JSON DB 是全量写盘,落盘耗时是平台最主要的延迟来源之一,必须可观测
|
||
metrics.ObserveDBSave(time.Since(start).Seconds(), err == nil)
|
||
return err
|
||
}
|
||
|
||
func (d *JsonDB) doSaveInner() error {
|
||
d.mu.RLock()
|
||
data, err := json.MarshalIndent(d, "", " ")
|
||
d.mu.RUnlock()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 原子写入:先写入临时文件,再重命名
|
||
dir := filepath.Dir(d.dataFile)
|
||
tmpFile := filepath.Join(dir, fmt.Sprintf(".lokistack-%d.tmp", time.Now().UnixNano()))
|
||
|
||
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 确保数据落盘
|
||
f, err := os.Open(tmpFile)
|
||
if err != nil {
|
||
os.Remove(tmpFile)
|
||
return err
|
||
}
|
||
_ = f.Sync()
|
||
f.Close()
|
||
|
||
if err := os.Rename(tmpFile, d.dataFile); err != nil {
|
||
os.Remove(tmpFile)
|
||
return err
|
||
}
|
||
metrics.DBSizeBytes.Set(float64(len(data)))
|
||
|
||
return nil
|
||
}
|
||
|
||
// Save 同步保存数据库到文件(原子写入)
|
||
func (d *JsonDB) Save() error {
|
||
return d.doSave()
|
||
}
|
||
|
||
// RequestSave 请求异步保存(防抖机制+序列化channel)
|
||
// 多次快速调用会在500ms内合并为一次保存
|
||
func (d *JsonDB) RequestSave() {
|
||
d.saveMu.Lock()
|
||
defer d.saveMu.Unlock()
|
||
|
||
// 如果已有待触发的定时器,重置它(防抖)
|
||
if d.saveTimer != nil {
|
||
d.saveTimer.Stop()
|
||
}
|
||
|
||
d.saveTimer = time.AfterFunc(500*time.Millisecond, func() {
|
||
d.saveMu.Lock()
|
||
d.saveTimer = nil
|
||
d.saveMu.Unlock()
|
||
|
||
// 通过channel发送到保存worker,确保序列化
|
||
select {
|
||
case d.saveChan <- saveRequest{}:
|
||
case <-d.stopChan:
|
||
}
|
||
})
|
||
}
|
||
|
||
// RequestSaveSync 请求同步保存,等待保存完成(用于shutdown场景)
|
||
func (d *JsonDB) RequestSaveSync() error {
|
||
req := saveRequest{done: make(chan error, 1)}
|
||
select {
|
||
case d.saveChan <- req:
|
||
case <-d.stopChan:
|
||
return d.Save() // worker已停止,直接同步保存
|
||
}
|
||
return <-req.done
|
||
}
|
||
|
||
func (d *JsonDB) NextID(table string) uint {
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
d.sequences[table]++
|
||
return d.sequences[table]
|
||
}
|
||
|
||
func (d *JsonDB) Lock() {
|
||
d.mu.Lock()
|
||
}
|
||
|
||
func (d *JsonDB) Unlock() {
|
||
d.mu.Unlock()
|
||
}
|
||
|
||
func (d *JsonDB) RLock() {
|
||
d.mu.RLock()
|
||
}
|
||
|
||
func (d *JsonDB) RUnlock() {
|
||
d.mu.RUnlock()
|
||
}
|
||
|
||
// CreateUser 创建用户并触发防抖保存
|
||
func (d *JsonDB) CreateUser(user *models.User) error {
|
||
d.mu.Lock()
|
||
defer d.mu.Unlock()
|
||
if user.ID == 0 {
|
||
user.ID = d.sequences["users"] + 1
|
||
d.sequences["users"] = user.ID
|
||
}
|
||
if user.CreatedAt.IsZero() {
|
||
now := time.Now()
|
||
user.CreatedAt = now
|
||
user.UpdatedAt = now
|
||
}
|
||
d.Users = append(d.Users, *user)
|
||
d.RequestSave()
|
||
return nil
|
||
}
|
||
|
||
// RecoverOrphanDeployments 启动时把残留的 deploying 状态部署标记为失败:
|
||
// 部署 goroutine 随进程消亡,不恢复会永久卡在「部署中」(UI 无停止入口)。
|
||
func (d *JsonDB) RecoverOrphanDeployments() {
|
||
d.Lock()
|
||
defer d.Unlock()
|
||
recovered := 0
|
||
for i := range d.Deployments {
|
||
if d.Deployments[i].Status == "deploying" {
|
||
d.Deployments[i].Status = "failed"
|
||
d.Deployments[i].StatusMessage = "服务重启导致部署中断,请重新部署"
|
||
d.Deployments[i].UpdatedAt = time.Now()
|
||
recovered++
|
||
}
|
||
}
|
||
if recovered > 0 {
|
||
d.RequestSave()
|
||
}
|
||
}
|