Files
LokiStack/backend/services/backup.go
T
opsx b49677f6a6 feat: 运维标杆化打磨 P0/P1/P2 —— 审计日志、真实指标、备份恢复、菜单收敛
审计发现 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 通过
2026-10-28 21:27:01 +00:00

338 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"lokistack/repository"
)
// 备份/恢复设计要点
//
// 为什么必须有:平台的全部状态(部署/采集/存储/集群/主机/策略/告警/用户/审计)
// 都在这一个 JSON 文件里;kubeconfig 虽然加密存在 DB 内,但解密依赖
// ENCRYPTION_KEY(K8s Secret)。DB 丢了 = 平台失去"这些资源是谁建的"的映射,
// 从此无法管理/卸载/升级正在跑的 Loki 与采集器。
//
// 安全取舍:
// - 备份包内含加密后的 kubeconfig 与 known_hosts,**不含明文密钥**
// - manifest 里只记录 ENCRYPTION_KEY 的指纹(sha256 前 16 位),
// 用于恢复时校验"这把钥匙能不能解开这份备份",不泄露密钥本身
// - 恢复分两步(校验暂存 → 手动应用),避免一个误点就覆盖线上 DB
// BackupManifest 备份包元信息(放在包内 manifest.json)
type BackupManifest struct {
Version string `json:"version"`
CreatedAt time.Time `json:"createdAt"`
DBBytes int64 `json:"dbBytes"`
DBSHA256 string `json:"dbSha256"`
KeyFP string `json:"encryptionKeyFingerprint"`
RecordCount int `json:"recordCount"`
Note string `json:"note"`
}
// backupExclude 备份时跳过的文件/目录(临时文件、lost+found、正在写的 restore 暂存)
var backupExclude = map[string]bool{
"lost+found": true, ".restore-pending": true, "backups": true,
}
// CreateBackup 把 /app/data 打包成 tar.gz 写入 w。
func CreateBackup(w io.Writer) (*BackupManifest, error) {
dataDir := dataDirPath()
dbPath := databasePath()
dbInfo, err := os.Stat(dbPath)
if err != nil {
return nil, fmt.Errorf("读取数据库失败: %w", err)
}
dbBytes, err := os.ReadFile(dbPath)
if err != nil {
return nil, fmt.Errorf("读取数据库失败: %w", err)
}
sum := sha256.Sum256(dbBytes)
db := repository.GetDB()
recordCount := 0
if db != nil {
db.RLock()
recordCount = len(db.Users) + len(db.Clusters) + len(db.Hosts) + len(db.Storages) +
len(db.Deployments) + len(db.Collectors) + len(db.LogSources) +
len(db.AlertRules) + len(db.NotifyChannels) + len(db.LogPolicyGroups) + len(db.AuditLogs)
db.RUnlock()
}
manifest := &BackupManifest{
Version: "2.0.0",
CreatedAt: time.Now(),
DBBytes: dbInfo.Size(),
DBSHA256: hex.EncodeToString(sum[:]),
KeyFP: encryptionKeyFingerprint(),
RecordCount: recordCount,
Note: "包内 kubeconfig 为加密态;恢复必须使用同一把 ENCRYPTION_KEY,否则 kubeconfig 无法解密",
}
gz := gzip.NewWriter(w)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
// 1) manifest
mBytes, _ := json.MarshalIndent(manifest, "", " ")
if err := writeTarFile(tw, "manifest.json", mBytes, 0o600); err != nil {
return nil, err
}
// 2) data 目录下所有文件(DB + kubeconfigs/ + known_hosts/)
err = filepath.Walk(dataDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // 单个文件读不到不阻断整包
}
if path == dataDir {
return nil
}
rel, err := filepath.Rel(dataDir, path)
if err != nil {
return nil
}
top := strings.SplitN(rel, string(os.PathSeparator), 2)[0]
if backupExclude[top] {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
if info.IsDir() {
return nil
}
// 跳过正在写的临时文件
if strings.HasPrefix(filepath.Base(path), ".lokistack-") && strings.HasSuffix(path, ".tmp") {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
hdr := &tar.Header{
Name: filepath.ToSlash(filepath.Join("data", rel)),
Mode: int64(info.Mode().Perm()),
Size: info.Size(),
ModTime: info.ModTime(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err = io.Copy(tw, f)
return err
})
if err != nil {
return nil, err
}
return manifest, nil
}
func writeTarFile(tw *tar.Writer, name string, data []byte, mode int64) error {
hdr := &tar.Header{Name: name, Mode: mode, Size: int64(len(data)), ModTime: time.Now()}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(data)
return err
}
// RestoreResult 校验结果
type RestoreResult struct {
Manifest *BackupManifest `json:"manifest"`
KeyMatches bool `json:"keyMatches"`
RowsInBackup int `json:"rowsInBackup"`
CurrentRows int `json:"currentRows"`
StagedPath string `json:"stagedPath,omitempty"`
Warning string `json:"warning,omitempty"`
}
// ValidateAndStageBackup 校验备份包并暂存到 <dataDir>/.restore-pending/。
//
// 不在这一步直接覆盖线上 DB:恢复是不可逆操作,必须让人再确认一次
// (页面上的「应用恢复」或 run.sh restore)。
func ValidateAndStageBackup(r io.Reader) (*RestoreResult, error) {
gr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("不是合法的 gzip 文件: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
var (
manifest *BackupManifest
dbBytes []byte
)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("解包失败: %w", err)
}
switch {
case hdr.Name == "manifest.json":
b, _ := io.ReadAll(io.LimitReader(tr, 1<<20))
var m BackupManifest
if err := json.Unmarshal(b, &m); err == nil {
manifest = &m
}
case hdr.Name == "data/opsxloki.json":
dbBytes, _ = io.ReadAll(io.LimitReader(tr, 64<<20))
}
}
if manifest == nil {
return nil, fmt.Errorf("备份包缺少 manifest.json,不是本平台导出的备份")
}
if len(dbBytes) == 0 {
return nil, fmt.Errorf("备份包内没有 data/opsxloki.json")
}
// 校验 DB 完整性:必须是合法 JSON 且能解析出预期集合
var probe map[string]json.RawMessage
if err := json.Unmarshal(dbBytes, &probe); err != nil {
return nil, fmt.Errorf("备份内的数据库不是合法 JSON: %w", err)
}
for _, required := range []string{"users", "deployments", "collectors"} {
if _, ok := probe[required]; !ok {
return nil, fmt.Errorf("备份内数据库缺少集合 %q,可能不是 LokiStack 的备份", required)
}
}
// 哈希校验(防止传输损坏)
sum := sha256.Sum256(dbBytes)
if manifest.DBSHA256 != "" && hex.EncodeToString(sum[:]) != manifest.DBSHA256 {
return nil, fmt.Errorf("备份内数据库校验失败(sha256 不匹配),文件可能已损坏")
}
stagedDir := filepath.Join(dataDirPath(), ".restore-pending")
if err := os.MkdirAll(stagedDir, 0o700); err != nil {
return nil, fmt.Errorf("创建暂存目录失败: %w", err)
}
stagedPath := filepath.Join(stagedDir, "opsxloki.json")
if err := os.WriteFile(stagedPath, dbBytes, 0o600); err != nil {
return nil, fmt.Errorf("写入暂存文件失败: %w", err)
}
currentRows := 0
if db := repository.GetDB(); db != nil {
db.RLock()
currentRows = len(db.Deployments) + len(db.Collectors) + len(db.Clusters) + len(db.Hosts)
db.RUnlock()
}
res := &RestoreResult{
Manifest: manifest,
KeyMatches: manifest.KeyFP == "" || manifest.KeyFP == encryptionKeyFingerprint(),
RowsInBackup: manifest.RecordCount,
CurrentRows: currentRows,
StagedPath: stagedPath,
}
if !res.KeyMatches {
res.Warning = "备份的 ENCRYPTION_KEY 指纹与当前平台不一致:恢复后 kubeconfig / 存储凭证将无法解密," +
"需要用备份时的那把 ENCRYPTION_KEY 覆盖 lokistack-secrets 中的值后再启动"
}
return res, nil
}
// encryptionKeyFingerprint 返回当前 ENCRYPTION_KEY 的指纹(sha256 前 16 位),不泄露密钥。
func encryptionKeyFingerprint() string {
key := os.Getenv("ENCRYPTION_KEY")
if key == "" {
// 兼容从文件读取的部署方式
if p := os.Getenv("ENCRYPTION_KEY_FILE"); p != "" {
if b, err := os.ReadFile(p); err == nil {
key = strings.TrimSpace(string(b))
}
}
}
if key == "" {
return ""
}
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])[:16]
}
// ApplyStagedBackup 把暂存文件应用为正式 DB(调用方负责重启进程)。
func ApplyStagedBackup() (string, error) {
staged := filepath.Join(dataDirPath(), ".restore-pending", "opsxloki.json")
b, err := os.ReadFile(staged)
if err != nil {
return "", fmt.Errorf("没有待应用的恢复文件: %w", err)
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(b, &probe); err != nil {
return "", fmt.Errorf("暂存文件已损坏: %w", err)
}
dbPath := databasePath()
// 先把当前 DB 备份一份,恢复错了还能退回去
if cur, err := os.ReadFile(dbPath); err == nil {
rollback := dbPath + ".before-restore." + time.Now().Format("20060102-150405")
_ = os.WriteFile(rollback, cur, 0o600)
}
tmp := dbPath + ".restoring"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, dbPath); err != nil {
return "", err
}
_ = os.Remove(staged)
return dbPath, nil
}
// ListBackups 列出 dataDir/backups 下已有的备份文件(run.sh backup 产出)。
func ListBackups() []map[string]interface{} {
dir := filepath.Join(dataDirPath(), "backups")
entries, err := os.ReadDir(dir)
if err != nil {
return []map[string]interface{}{}
}
type item struct {
name string
size int64
mod time.Time
}
var items []item
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".tar.gz") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
items = append(items, item{e.Name(), info.Size(), info.ModTime()})
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
out := make([]map[string]interface{}, 0, len(items))
for _, it := range items {
out = append(out, map[string]interface{}{
"name": it.name, "sizeBytes": it.size, "createdAt": it.mod,
})
}
return out
}
func dataDirPath() string {
if p := os.Getenv("DATA_DIR"); p != "" {
return p
}
return "/app/data"
}