refactor: 移除备份恢复功能(API/CLI/手册)
CI / Backend (go vet + test) (push) Canceled after 0s
CI / Frontend (lint + build + test) (push) Canceled after 0s

日志数据的保留与安全由对象存储 + Loki retention 承担,平台侧整库备份恢复价值有限:
删除 services/backup.go、/system/{backup,restore,restore/apply,backups} 路由与 handler、
CLI backup/restore 子命令、前端 api 方法与类型;自检所需的 dataDirPath/密钥指纹并入 selfcheck;
用户手册删备份章节并修正 FAQ 升级建议;CHANGELOG 记录移除。
This commit is contained in:
opsx committed 2026-10-29 14:48:25 +08:00
1 parent e07fe55c86
commit f87fb4b2b3
11 files changed
+34 -552

No files matched your search

+3
View File
@@ -5,6 +5,9 @@
## [Unreleased] ## [Unreleased]
### 移除
- 备份恢复功能(含 API / CLI / 手册):日志数据本体的保留与安全由对象存储与 Loki retention 承担,平台侧备份价值有限
### 新增 ### 新增
- 落地页(homepage/,`/homepage` 访问):产品介绍、特性、快速开始 - 落地页(homepage/,`/homepage` 访问):产品介绍、特性、快速开始
- 用户手册文档站(docsify,`/docs` 访问):安装、部署向导、采集、查询、分析、告警、授权、备份、FAQ 十篇 - 用户手册文档站(docsify,`/docs` 访问):安装、部署向导、采集、查询、分析、告警、授权、备份、FAQ 十篇
-65
View File
@@ -1,9 +1,7 @@
package controllers package controllers
import ( import (
"fmt"
"lokistack/pkg/metrics" "lokistack/pkg/metrics"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,71 +17,8 @@ func NewSystemController() *SystemController { return &SystemController{} }
// Backup 导出当前平台数据(tar.gz 下载)。 // Backup 导出当前平台数据(tar.gz 下载)。
// //
// 内容:数据文件 + kubeconfigs/ + known_hosts/ + manifest.json(含密钥指纹), // 内容:数据文件 + kubeconfigs/ + known_hosts/ + manifest.json(含密钥指纹),
// 不含明文密钥。恢复必须使用同一把 ENCRYPTION_KEY。
func (ctrl *SystemController) Backup(c *gin.Context) {
name := fmt.Sprintf("lokistack-backup-%s.tar.gz", time.Now().Format("20060102-150405"))
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
if _, err := services.CreateBackup(c.Writer); err != nil {
// 已经写了响应头,无法再改状态码,只能把错误写进 body 尾部
_, _ = c.Writer.WriteString(fmt.Sprintf("\n\n# 备份失败: %v", err))
return
}
}
// Restore 上传备份包:校验 + 暂存,**不直接覆盖**线上数据库。 // Restore 上传备份包:校验 + 暂存,**不直接覆盖**线上数据库。
// 返回暂存路径与密钥指纹比对结果,由管理员确认后再调用 RestoreApply。
func (ctrl *SystemController) Restore(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.BadRequest(c, "请上传备份文件(form-data 字段名 file)")
return
}
if file.Size > 128<<20 {
utils.BadRequest(c, "备份文件超过 128MB,请用 run.sh restore 在服务器上操作")
return
}
f, err := file.Open()
if err != nil {
utils.InternalServerError(c, fmt.Sprintf("读取上传文件失败: %v", err))
return
}
defer f.Close()
res, err := services.ValidateAndStageBackup(f)
if err != nil {
utils.BadRequest(c, fmt.Sprintf("备份校验失败: %v", err))
return
}
utils.Success(c, res)
}
// RestoreApply 应用已暂存的备份。应用后需重启平台进程才生效。
func (ctrl *SystemController) RestoreApply(c *gin.Context) {
var req struct {
Confirm bool `json:"confirm"`
}
_ = c.ShouldBindJSON(&req)
if !req.Confirm {
utils.BadRequest(c, "恢复会覆盖当前全部数据,请传 confirm=true 确认")
return
}
path, err := services.ApplyStagedBackup()
if err != nil {
utils.BadRequest(c, err.Error())
return
}
utils.Success(c, gin.H{
"message": "备份已应用,请重启平台使新数据生效(kubectl -n log rollout restart deploy/lokistack)",
"dbPath": path,
})
}
// Backups 列出服务器上已有的备份文件(run.sh backup 产出)。
func (ctrl *SystemController) Backups(c *gin.Context) {
utils.Success(c, gin.H{"list": services.ListBackups()})
}
// GetRetention 读取平台数据保留期(告警发送历史 / 审计日志)。 // GetRetention 读取平台数据保留期(告警发送历史 / 审计日志)。
func (ctrl *SystemController) GetRetention(c *gin.Context) { func (ctrl *SystemController) GetRetention(c *gin.Context) {
+4 -61
View File
@@ -39,79 +39,22 @@ func fileExists(path string) bool {
// runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。 // runCLI 提供不依赖 Web 的运维命令(备份 CronJob、恢复、自检都走它)。
// //
// lokistack backup --output /app/data/backups/xxx.tar.gz
// lokistack restore <备份包路径> # 校验 + 立即应用(会先备份当前 DB)
// lokistack self-check # 打印 JSON 自检结果 // lokistack self-check # 打印 JSON 自检结果
func runCLI(args []string) { func runCLI(args []string) {
switch args[0] { switch args[0] {
case "backup":
out := ""
for i := 1; i < len(args); i++ {
if args[i] == "--output" && i+1 < len(args) {
out = args[i+1]
}
}
if out == "" {
out = "/app/data/backups/lokistack-" + time.Now().Format("20060102-150405") + ".tar.gz"
}
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
fmt.Fprintf(os.Stderr, "创建备份目录失败: %v\n", err)
os.Exit(1)
}
f, err := os.Create(out)
if err != nil {
fmt.Fprintf(os.Stderr, "创建备份文件失败: %v\n", err)
os.Exit(1)
}
defer f.Close()
m, err := services.CreateBackup(f)
if err != nil {
fmt.Fprintf(os.Stderr, "备份失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("备份完成: %s\n记录数: %d\n密钥指纹: %s\n", out, m.RecordCount, m.KeyFP)
case "restore":
if len(args) < 2 {
fmt.Fprintln(os.Stderr, "用法: lokistack restore <备份包.tar.gz>")
os.Exit(1)
}
f, err := os.Open(args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "打开备份失败: %v\n", err)
os.Exit(1)
}
defer f.Close()
res, err := services.ValidateAndStageBackup(f)
if err != nil {
fmt.Fprintf(os.Stderr, "校验失败: %v\n", err)
os.Exit(1)
}
if res.Warning != "" {
fmt.Fprintf(os.Stderr, "⚠️ %s\n", res.Warning)
}
path, err := services.ApplyStagedBackup()
if err != nil {
fmt.Fprintf(os.Stderr, "应用失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("恢复完成: %s(备份 %d 条记录)\n请重启平台进程使新数据生效。\n", path, res.RowsInBackup)
case "self-check": case "self-check":
b, _ := json.MarshalIndent(services.SelfCheck(), "", " ") out, _ := json.MarshalIndent(services.SelfCheck(), "", " ")
fmt.Println(string(b)) fmt.Println(string(out))
case "version": case "version":
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime) fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
default: default:
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: backup / restore / self-check / version\n", args[0]) fmt.Fprintf(os.Stderr, "未知命令 %q。可用: self-check / version\n", args[0])
os.Exit(1) os.Exit(1)
} }
} }
func main() { func main() {
// 运维命令行:支持在容器/CronJob 里不经 Web 完成备份/恢复/自检 // 运维命令行:容器/CronJob 里不经 Web 完成自检与版本查询
if len(os.Args) > 1 { if len(os.Args) > 1 {
runCLI(os.Args[1:]) runCLI(os.Args[1:])
return return
-4
View File
@@ -147,10 +147,6 @@ func SetupRouter() *gin.Engine {
system := authorized.Group("/system") system := authorized.Group("/system")
system.Use(middleware.AdminAuth()) system.Use(middleware.AdminAuth())
{ {
system.GET("/backup", systemCtrl.Backup)
system.POST("/restore", systemCtrl.Restore)
system.POST("/restore/apply", systemCtrl.RestoreApply)
system.GET("/backups", systemCtrl.Backups)
system.GET("/self-check", systemCtrl.Health) system.GET("/self-check", systemCtrl.Health)
system.GET("/metrics-summary", middleware.AdminAuth(), systemCtrl.MetricsSummary) system.GET("/metrics-summary", middleware.AdminAuth(), systemCtrl.MetricsSummary)
} }
-364
View File
@@ -1,364 +0,0 @@
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)
// 记录数从**备份出来的字节**里数,不用 repository.GetDB():
// CLI 模式(lokistack backup,跑在 CronJob 里)根本没初始化 DB,
// 用 GetDB() 会得到 nil,manifest 里的 recordCount 恒为 0,
// 恢复时就看不出"这份备份里有没有东西"。
recordCount := countRecords(dbBytes)
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
}
// countRecords 从数据库 JSON 字节里统计各集合的记录总数。
func countRecords(dbBytes []byte) int {
var raw map[string]json.RawMessage
if err := json.Unmarshal(dbBytes, &raw); err != nil {
return 0
}
total := 0
for _, v := range raw {
var arr []json.RawMessage
if err := json.Unmarshal(v, &arr); err == nil {
total += len(arr)
}
}
return total
}
// 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) {
restoreDir := filepath.Join(dataDirPath(), ".restore-pending")
staged := filepath.Join(restoreDir, "lokistack.json")
if _, err := os.Stat(staged); err != nil {
// 兼容旧版暂存文件名
staged = filepath.Join(restoreDir, "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)
// 关键:立即把内存态切换为恢复后的数据。否则「替换文件 → 重启」之间,
// 任何一次防抖落盘都会用旧内存态把恢复文件覆盖回去,恢复静默失效。
if db := repository.GetDB(); db != nil {
db.Lock()
if err := db.Reload(); err != nil {
db.Unlock()
return dbPath, fmt.Errorf("恢复文件已生效但内存重载失败(%v),请立即重启进程", err)
}
db.Unlock()
}
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"
}
+24
View File
@@ -1,10 +1,14 @@
package services package services
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"lokistack/config"
"net/http" "net/http"
"os" "os"
"path/filepath"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -263,6 +267,11 @@ func fileDBStats(path string) string {
count("deployments"), count("collectors"), count("auditLogs")) count("deployments"), count("collectors"), count("auditLogs"))
} }
// dataDirPath 数据目录绝对路径(自检磁盘空间等场景使用)
func dataDirPath() string {
return config.GetConfig().DataDir
}
// diskUsage 返回剩余与总空间(字节) // diskUsage 返回剩余与总空间(字节)
func diskUsage(path string) (free, total uint64, err error) { func diskUsage(path string) (free, total uint64, err error) {
var st syscall.Statfs_t var st syscall.Statfs_t
@@ -271,3 +280,18 @@ func diskUsage(path string) (free, total uint64, err error) {
} }
return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize), nil return st.Bavail * uint64(st.Bsize), st.Blocks * uint64(st.Bsize), nil
} }
// encryptionKeyFingerprint ENCRYPTION_KEY 指纹(SHA256 前 16 位,自检展示用,不泄露密钥)
func encryptionKeyFingerprint() string {
key := config.GetConfig().EncryptionKey
if key == "" {
if b, err := os.ReadFile(filepath.Join(config.GetConfig().DataDir, ".encryption-key")); err == nil {
key = strings.TrimSpace(string(b))
}
}
if key == "" {
return ""
}
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])[:16]
}
+1 -1
View File
@@ -8,7 +8,7 @@ LokiStack 把 Grafana Loki 日志聚合的部署、采集、查询、分析与
- **采集日志**:Promtail / Fluent Bit 采集策略可视化配置,命名空间多选、app 标签来源四级回退、敏感路径拦截。 - **采集日志**:Promtail / Fluent Bit 采集策略可视化配置,命名空间多选、app 标签来源四级回退、敏感路径拦截。
- **查询与分析**:LogQL 高亮补全、快捷查询、Live Tail;分析页自动聚合日志量、错误与 Ingress 访问指标。 - **查询与分析**:LogQL 高亮补全、快捷查询、Live Tail;分析页自动聚合日志量、错误与 Ingress 访问指标。
- **告警**:内置九条应用日志规则,绑定默认通道后填入飞书 Webhook 即可接收;支持冷却与风暴抑制。 - **告警**:内置九条应用日志规则,绑定默认通道后填入飞书 Webhook 即可接收;支持冷却与风暴抑制。
- **运维底座**:凭证加密存储、操作审计、备份恢复、Prometheus 自监控指标。 - **运维底座**:凭证加密存储、操作审计、Prometheus 自监控指标。
## 下一步 ## 下一步
-1
View File
@@ -6,5 +6,4 @@
- [日志分析](analysis.md) - [日志分析](analysis.md)
- [告警通知](alerts.md) - [告警通知](alerts.md)
- [查询授权](access.md) - [查询授权](access.md)
- [备份与恢复](backup.md)
- [常见问题](faq.md) - [常见问题](faq.md)
-23
View File
@@ -1,23 +0,0 @@
# 备份与恢复
平台状态集中在单个 JSON 文件(`$DATA_DIR/lokistack.json`),备份恢复围绕它展开。
## 备份
- 手动:「系统设置 → 备份」立即创建(写入 `$DATA_DIR/backups/`)。
- 脚本:`./run.sh backup` 或 `make` 内相应目标,适合 cron 定时。
## 恢复
1. 上传统计备份或选择已有备份,校验后暂存。
2. 点击应用:平台先把当前库留档(`.before-restore.*`),替换后立即在内存生效,随后建议重启进程。
3. 恢复文件损坏时平台拒绝应用并提示。
## 数据文件保护
- 启动时若数据文件解析失败,原文件会以 `.corrupt-<时间戳>` 留档后再以空库启动,绝不覆盖可恢复数据。
- 历史命名数据文件(opsxloki.json / lokideploy.json)在启动时自动升级为 lokistack.json。
## 自监控
`/api/v1/metrics` 输出 Prometheus 指标(HTTP、DB 落盘、告警引擎、Go runtime);「平台监控」页(`/monitor`,管理员)提供可视化快照。
+2 -2
View File
@@ -22,8 +22,8 @@
## 平台可以多实例部署吗? ## 平台可以多实例部署吗?
不可以。JSON 文件存储为单实例设计(防抖串行落盘),水平扩展会互相覆盖;需要高可用时用单实例 + 备份策略。 不可以。JSON 文件存储为单实例设计(防抖串行落盘),水平扩展会互相覆盖;需要高可用时请保持单实例并对数据目录做系统级快照。
## 升级安全吗? ## 升级安全吗?
配置与数据向后兼容,结构变更按 schema 版本自动迁移;建议升级前在「系统设置」创建一次备份。 配置与数据向后兼容,结构变更按 schema 版本自动迁移;建议升级前对数据目录(lokistack.json)做一次文件级拷贝。
-31
View File
@@ -65,27 +65,6 @@ export interface SelfCheckResult {
items: SelfCheckItem[]; items: SelfCheckItem[];
} }
export interface BackupFile {
name: string;
sizeBytes: number;
createdAt: string;
}
export interface RestoreResult {
manifest: {
version: string;
createdAt: string;
dbBytes: number;
recordCount: number;
note: string;
};
keyMatches: boolean;
rowsInBackup: number;
currentRows: number;
stagedPath?: string;
warning?: string;
}
export const systemApi = { export const systemApi = {
// 审计 // 审计
auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) => auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) =>
@@ -99,16 +78,6 @@ export const systemApi = {
// 自检 // 自检
selfCheck: () => http.get<SelfCheckResult>('/system/self-check'), selfCheck: () => http.get<SelfCheckResult>('/system/self-check'),
// 备份恢复
backups: () => http.get<{ list: BackupFile[] }>('/system/backups'),
restore: (file: File) => {
const form = new FormData();
form.append('file', file);
return http.post<RestoreResult>('/system/restore', form);
},
restoreApply: () =>
http.post<{ message: string; dbPath: string }>('/system/restore/apply', { confirm: true }),
// 平台数据保留期(告警发送历史 / 审计日志) // 平台数据保留期(告警发送历史 / 审计日志)
retention: () => http.get<RetentionSettings>('/settings/retention'), retention: () => http.get<RetentionSettings>('/settings/retention'),
saveRetention: (data: RetentionSettings) => http.put<RetentionSettings>('/settings/retention', data), saveRetention: (data: RetentionSettings) => http.put<RetentionSettings>('/settings/retention', data),