fix(logs,analysis,alerts): 数据源不可达、分析页超时、告警规则混入基础设施项

=== 1. 主机模式数据源地址 0.0.0.0 → 主机真实 IP(查询页"永远加载中"的根因)===
现象:切换数据源后命名空间/App 下拉不动,一直显示"加载中…",控制台报
    Get "http://0.0.0.0:3100/loki/api/v1/labels": dial tcp 0.0.0.0:3100: connect: connection refused

根因:主机模式部署的 endpoint 记的是**容器监听地址** 0.0.0.0:3100,
而平台进程不在被采集主机上,0.0.0.0 只在本机有意义。
EnsureLogSourceForDeployment 把它原样写成了数据源 URL。

修法:
  - 新增 reachableEndpoint():把 0.0.0.0 / [::] 换成关联主机的真实 IP
  - schemaVersion v3→v4 迁移 fixUnreachableLogSourceURLs(),修正已有脏数据
  - 实测:http://0.0.0.0:3100 → http://172.19.16.8:3100,该源 label 查询由 502 变 200

=== 2. 前端:区分"加载中"与"加载失败" ===
原来用 `loading={!options.length}` 表示加载中 —— 数据源不可达时列表永远为空,
于是界面永远转圈,把真实错误完全藏起来了(这次的 0.0.0.0 就是这样被掩盖的)。
改为独立的 namespaceLoading / namespaceError 两个状态:
  - 切换数据源先清空旧 options(否则显示上一个源的命名空间,看着像"没反应")
  - 失败显示"加载失败"并在旁边给「重试」按钮,不用刷新整页

=== 3. 日志分析页:主图改走索引接口(原来 5 条 count_over_time 全超时)===
实测 astralx(约 370GB/天)15 分钟窗口:
  count_over_time   → exec 29s + 排队 95s,超过 30s 护栏 → 502
  index/volume_range → 0s
改为两阶段渲染:
  - 总量趋势改用 /logs/histogram(内部 index/volume_range),**0s 出图**
  - 错误/警告/app 维度仍需读日志内容,串行执行,先渲染主图再更新
  - heavyQueryTimeout 30s → 90s(实测 error 维度查询 14s 可完成)

=== 4. 告警规则按日志平台定位收敛 ===
  - seed 里删掉「Kubernetes 节点 NotReady」「容器 OOMKilled」:
    这类属于 kube-state-metrics / Prometheus 的职责,用日志字符串匹配不可靠
  - 迁移里清掉线上已存在的同类规则(3 条)
  - 修 AlertRule / NotifyChannel 重复 ID(同 logSources 一样漏在 sequences 重建外)

=== 5. 告警页 UI ===
  - 规则列表由"每条一张卡"改为紧凑表格(一屏看全,阈值可对齐比较)
  - 通知通道(飞书/Lark/Webhook)从「系统设置」搬到告警页折叠区:
    规则和"发到哪"是一件事的两半,分两页配容易漏配导致告警发不出去

=== 6. 权限收敛 ===
  - /alerts 加 adminOnly:普通用户按查询授权只看「日志查询」,不再看到告警菜单
  - 系统设置从侧栏移到右上角齿轮(低频平台配置,不占侧栏一位)

=== 7. 迁移设计教训(写进代码注释)===
v2 时把"告警去重 + 清基础设施规则"塞进 case 1,但线上库已到 v2,
那段代码永远不执行。**结构变更只要逻辑变了、目标库已达该版本,就必须再抬一版**,
不能改历史迁移分支。

=== 实测(v4-1028-2210 / 979bdb4a48e8)===
  v4 迁移:数据源 URL 修正 http://0.0.0.0:3100 → http://172.19.16.8:3100
  source 1 label:200,0s(astralx / log)
  source 2 label:200,0s(不再 connection refused)
  分析页 histogram astralx 15m:0s
  分析页 metrics  error  astralx 15m:14s
  告警规则:10 条(已无基础设施类)、ID 唯一
  另清理 172.19.16.8 上 13 个残留容器,回收 6.1GB
This commit is contained in:
opsx committed 2026-10-28 22:11:48 +00:00
1 parent 1a4c75e28c
commit 95dcd9408c
20 files changed
+941 -87

No files matched your search

+288 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"strings"
"path/filepath"
"sync"
"time"
@@ -61,12 +62,239 @@ func GetDB() *JsonDB {
return db
}
// fixUnreachableLogSourceURLs 把数据源里 0.0.0.0 / [::] 的监听地址换成主机真实 IP。
//
// 平台进程不在被采集主机上,0.0.0.0 只在本机有意义,用它必然连不上。
func (d *JsonDB) fixUnreachableLogSourceURLs() {
hostIPByID := map[uint]string{}
for _, h := range d.Hosts {
hostIPByID[h.ID] = strings.TrimSpace(h.HostIP)
}
// 数据源按名字对应部署名,用于推断该源属于哪台主机
hostByName := map[string]string{}
for _, dep := range d.Deployments {
if dep.HostID == nil {
continue
}
if ip, ok := hostIPByID[*dep.HostID]; ok && ip != "" {
hostByName[dep.Name] = ip
}
}
for i := range d.LogSources {
src := &d.LogSources[i]
if !strings.Contains(src.URL, "0.0.0.0") && !strings.Contains(src.URL, "[::]") {
continue
}
ip := hostByName[src.Name]
if ip == "" && len(d.Hosts) == 1 {
// 只有一台主机时可以直接推断(常见于单机试用)
ip = strings.TrimSpace(d.Hosts[0].HostIP)
}
if ip == "" {
log.Printf("[schema] 数据源 %q 的 URL 是不可达的监听地址 %s,但无法推断主机 IP,请手动修正", src.Name, src.URL)
continue
}
old := src.URL
src.URL = strings.Replace(src.URL, "0.0.0.0", ip, 1)
src.URL = strings.Replace(src.URL, "[::]", ip, 1)
log.Printf("[schema] 数据源 %q URL 修正:%s → %s", src.Name, old, src.URL)
}
}
// dedupeCollectionIDs 对所有带 ID 的集合做重复 ID 修复。
//
// 注意:实际迁移里按版本分步调用(见 migrateSchema),
// 不要直接用它替代分步逻辑 —— 那会让"已迁移库"漏掉后加的清理步骤。
//
// 背景:这些集合曾经都漏在 InitDB 的 sequences 重建之外,导致 NextID 每次
// 进程重启都从 1 开始,于是不同时期创建的对象会撞号。撞号后的表现各不相同:
// LogSource : 查询页数据源下拉选不动(两个 option 同 value)
// AlertRule : 规则列表出现两条同 id,编辑/删除操作命中错的那条
// NotifyChannel: 通知渠道覆盖错对象
func (d *JsonDB) dedupeCollectionIDs() {
d.dedupeLogSourceIDs()
d.dedupeAlertRuleIDs()
d.dedupeNotifyChannelIDs()
}
// dedupeAlertRuleIDs 重新编号重复的告警规则 ID。
// 同名规则(seed 重复执行导致)只保留第一条,其余删除。
func (d *JsonDB) dedupeAlertRuleIDs() {
if len(d.AlertRules) == 0 {
return
}
seenID := map[uint]bool{}
seenName := map[string]bool{}
next := uint(0)
for _, r := range d.AlertRules {
if r.ID > next {
next = r.ID
}
}
fixed := make([]models.AlertRule, 0, len(d.AlertRules))
for i := range d.AlertRules {
rule := d.AlertRules[i]
if rule.Name != "" && seenName[rule.Name] {
log.Printf("[schema] 删除重复的告警规则 %q(同名)", rule.Name)
continue
}
seenName[rule.Name] = true
if rule.ID == 0 || seenID[rule.ID] {
next++
log.Printf("[schema] 告警规则 %q 重复 ID %d → 重新分配为 %d", rule.Name, rule.ID, next)
rule.ID = next
}
seenID[rule.ID] = true
fixed = append(fixed, rule)
}
d.AlertRules = fixed
for _, r := range d.AlertRules {
if r.ID > d.sequences["alertRules"] {
d.sequences["alertRules"] = r.ID
}
}
}
// dedupeNotifyChannelIDs 重新编号重复的通知渠道 ID。
func (d *JsonDB) dedupeNotifyChannelIDs() {
if len(d.NotifyChannels) == 0 {
return
}
seen := map[uint]bool{}
next := uint(0)
for _, c := range d.NotifyChannels {
if c.ID > next {
next = c.ID
}
}
for i := range d.NotifyChannels {
ch := &d.NotifyChannels[i]
if ch.ID == 0 || seen[ch.ID] {
next++
log.Printf("[schema] 通知渠道 %q 重复 ID %d → 重新分配为 %d", ch.Name, ch.ID, next)
ch.ID = next
}
seen[ch.ID] = true
if ch.ID > d.sequences["notifyChannels"] {
d.sequences["notifyChannels"] = ch.ID
}
}
}
// removeInfraAlertRules 删除误配置的「基础设施类」告警规则。
//
// 判据:查询指向 kube-state-metrics / event-exporter,或在日志里匹配
// 节点 NotReady / 容器 OOMKilled。这些是 Prometheus 指标监控的职责:
// - 日志里有没有这行取决于采集器是否采到那些命名空间,不可靠
// - 指标侧(kube_node_status_condition / kube_pod_container_status_terminated_reason)
// 才是权威数据源
func (d *JsonDB) removeInfraAlertRules() {
infraMarkers := []string{"kube-state-metrics", "event-exporter"}
infraNames := []string{"Kubernetes 节点 NotReady", "K8s 节点 NotReady", "容器 OOMKilled"}
kept := make([]models.AlertRule, 0, len(d.AlertRules))
for _, r := range d.AlertRules {
isInfra := false
for _, marker := range infraMarkers {
if strings.Contains(r.Query, marker) {
isInfra = true
}
}
for _, name := range infraNames {
if strings.TrimSpace(r.Name) == name {
isInfra = true
}
}
if isInfra {
log.Printf("[schema] 移除基础设施类告警规则 %q(应交给 Prometheus)", r.Name)
continue
}
kept = append(kept, r)
}
d.AlertRules = kept
}
// dedupeLogSourceIDs 修复历史遗留的重复 ID,并同步更新引用它的策略组。
//
// 策略组通过 SourceID 关联数据源。重复 ID 时无法判断分组到底指向哪一个,
// 这里按"保留第一个、其余顺延"处理,并让原本指向该 ID 的策略组继续指向
// 编号最小的那个(即重建前第一次创建的数据源)。
func (d *JsonDB) dedupeLogSourceIDs() {
if len(d.LogSources) < 2 {
return
}
seen := map[uint]bool{}
remap := map[uint]uint{} // 旧ID → 新ID(仅记录需要改的)
next := uint(0)
for _, s := range d.LogSources {
if s.ID > next {
next = s.ID
}
}
fixed := make([]models.LogSource, 0, len(d.LogSources))
kept := map[uint]bool{} // 已被占用的 ID(首次出现时占位)
for i := range d.LogSources {
src := d.LogSources[i]
if src.ID == 0 || kept[src.ID] {
// 重复或非法 ID:分配新号
next++
old := src.ID
src.ID = next
if _, ok := remap[old]; !ok && old != 0 {
// 同一个旧 ID 只记录一次映射(指向保留的那个)
remap[old] = 0 // 0 表示"不需要改引用"
}
log.Printf("[schema] 数据源 %q 重复 ID %d → 重新分配为 %d", src.Name, old, src.ID)
} else {
kept[src.ID] = true
}
seen[src.ID] = true
fixed = append(fixed, src)
}
d.LogSources = fixed
// 同步策略组引用(只在旧 ID 已不存在时改写)
for i := range d.LogPolicyGroups {
sid := d.LogPolicyGroups[i].SourceID
if sid == 0 || seen[sid] {
continue
}
if nv, ok := remap[sid]; ok && nv != 0 {
d.LogPolicyGroups[i].SourceID = nv
}
}
// 迁移发生在 sequences 重建**之后**,重新分配可能抬高最大 ID,
// 必须同步提升计数器,否则紧接着创建的数据源会撞号。
for _, s := range d.LogSources {
if s.ID > d.sequences["logSources"] {
d.sequences["logSources"] = s.ID
}
}
// 保证只有一个默认数据源
defaultSeen := false
for i := range d.LogSources {
if d.LogSources[i].IsDefault {
if defaultSeen {
d.LogSources[i].IsDefault = false
}
defaultSeen = true
}
}
if !defaultSeen && len(d.LogSources) > 0 {
d.LogSources[0].IsDefault = true
}
}
// CurrentSchemaVersion 当前数据结构版本。
// 每次做**不兼容**的结构变更时 +1,并在 migrateSchema 里补一段迁移逻辑。
//
// v0/v1(无字段)→ 1:新增 auditLogs / schemaVersion(老库走零值兜底即可,无需处理)
// v1 → 2:预留示例(如把 ScrapeJobs 从 logPath 拆分时,需按旧结构生成默认 job)
const CurrentSchemaVersion = 1
const CurrentSchemaVersion = 4
// migrateSchema 按版本号顺序迁移老库。返回是否发生了迁移(需要回写)。
func (d *JsonDB) migrateSchema() bool {
@@ -86,6 +314,31 @@ func (d *JsonDB) migrateSchema() bool {
d.AuditLogs = []models.AuditLog{}
}
d.SchemaVersion = 1
case 1:
// v1 → v2:修复 LogSource 重复 ID(曾漏在 sequences 重建之外,
// NextID 每次重启从 1 开始,线上出现两个数据源都是 id=1)。
d.dedupeLogSourceIDs()
d.SchemaVersion = 2
case 2:
// v2 → v3:修复 AlertRule / NotifyChannel 重复 ID + 清掉基础设施类规则。
//
// ⚠️ 教训:v2 那次我把"告警去重 + 清基础设施规则"塞进了 case 1,
// 但当时线上库已迁移到 v2,于是这段代码永远不执行(DB 里 schemaVersion
// 已经等于当前版本)。结构变更只要改了逻辑、且目标库已到该版本,
// 就必须**再抬一版**,不能改历史迁移分支。
d.dedupeAlertRuleIDs()
d.dedupeNotifyChannelIDs()
d.removeInfraAlertRules()
d.SchemaVersion = 3
case 3:
// v3 → v4:修复主机模式数据源的不可达 URL。
//
// 主机模式部署的 endpoint 存的是容器监听地址 0.0.0.0:3100,
// 平台把它原样当数据源 URL → 查询页对该数据源的所有请求
// 都报 dial tcp 0.0.0.0:3100: connection refused,
// 前端表现为「命名空间/App 下拉永远加载中」。
d.fixUnreachableLogSourceURLs()
d.SchemaVersion = 4
default:
// 防御:出现未知版本(比当前代码还新)时不乱动,避免降级把数据写坏
log.Printf("[schema] 数据库版本 %d 高于当前代码支持版本 %d,跳过迁移", d.SchemaVersion, CurrentSchemaVersion)
@@ -126,6 +379,11 @@ func InitDB() error {
"collectors": 0,
"logs": 0,
"logPolicyGroups": 0,
"logSources": 0,
"notifyChannels": 0,
"alertRules": 0,
"alertDeliveries": 0,
"auditLogs": 0,
},
saveChan: make(chan saveRequest, 10),
stopChan: make(chan struct{}),
@@ -174,6 +432,35 @@ func InitDB() error {
d.sequences["logPolicyGroups"] = group.ID
}
}
// ⚠️ 以下集合曾经漏在重建之外,导致 NextID 每次重启都从 1 开始,
// 于是不同进程生命周期里创建的对象 ID 重复(线上实际出现过
// 两个 LogSource 都是 id=1 → 前端数据源下拉无法切换)。
// 以后新增任何带 ID 的集合,都必须在这里补一段。
for _, s := range d.LogSources {
if s.ID > d.sequences["logSources"] {
d.sequences["logSources"] = s.ID
}
}
for _, ch := range d.NotifyChannels {
if ch.ID > d.sequences["notifyChannels"] {
d.sequences["notifyChannels"] = ch.ID
}
}
for _, rule := range d.AlertRules {
if rule.ID > d.sequences["alertRules"] {
d.sequences["alertRules"] = rule.ID
}
}
for _, del := range d.AlertDeliveries {
if del.ID > d.sequences["alertDeliveries"] {
d.sequences["alertDeliveries"] = del.ID
}
}
for _, a := range d.AuditLogs {
if a.ID > d.sequences["auditLogs"] {
d.sequences["auditLogs"] = a.ID
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
package repository
import (
"testing"
"lokistack/models"
)
// 回归:nextSequence / 去重逻辑。
//
// 线上事故:logSources 漏在 sequences 重建之外,NextID 每次重启从 1 开始,
// 导致两个数据源都是 id=1 → 前端数据源下拉两个 option value 相同,
// 选中后 sourceId 不变,界面表现为"无法切换数据源"。
func TestDedupeLogSourceIDs(t *testing.T) {
db := &JsonDB{
sequences: map[string]uint{"logSources": 1},
LogSources: []models.LogSource{
{ID: 1, Name: "loki-pre", IsDefault: true},
{ID: 1, Name: "host-verify-loki"}, // 重复 ID
},
LogPolicyGroups: []models.LogPolicyGroup{
{ID: 1, Name: "g1", SourceID: 1},
},
}
db.dedupeLogSourceIDs()
if len(db.LogSources) != 2 {
t.Fatalf("数据源数量变化了:%d", len(db.LogSources))
}
ids := map[uint]string{}
for _, s := range db.LogSources {
if _, dup := ids[s.ID]; dup {
t.Fatalf("仍然存在重复 ID %d", s.ID)
}
ids[s.ID] = s.Name
}
if ids[1] != "loki-pre" {
t.Fatalf("第一个数据源应保留 ID=1,实际 %v", ids)
}
// 被重排的那个必须拿到新号
if _, ok := ids[2]; !ok {
t.Fatalf("重复项应被重排为 ID=2,实际 %v", ids)
}
// 策略组仍指向保留下来的那个
if db.LogPolicyGroups[0].SourceID != 1 {
t.Fatalf("策略组引用被误改:%d", db.LogPolicyGroups[0].SourceID)
}
// 计数器必须已提升,防止紧接着创建又撞号
if db.sequences["logSources"] < 2 {
t.Fatalf("sequences 未提升:%d", db.sequences["logSources"])
}
}
// 只有一个数据源时不应被改动
func TestDedupeLogSourceIDsNoop(t *testing.T) {
db := &JsonDB{
sequences: map[string]uint{"logSources": 1},
LogSources: []models.LogSource{{ID: 1, Name: "only"}},
}
db.dedupeLogSourceIDs()
if len(db.LogSources) != 1 || db.LogSources[0].ID != 1 {
t.Fatalf("单数据源不应被改动:%+v", db.LogSources)
}
}
// 多个默认数据源只保留第一个
func TestDedupeKeepsSingleDefault(t *testing.T) {
db := &JsonDB{
sequences: map[string]uint{"logSources": 2},
LogSources: []models.LogSource{
{ID: 1, Name: "a", IsDefault: true},
{ID: 2, Name: "b", IsDefault: true},
},
}
db.dedupeLogSourceIDs()
if !db.LogSources[0].IsDefault {
t.Fatal("第一个应保持默认")
}
if db.LogSources[1].IsDefault {
t.Fatal("第二个不应再是默认")
}
}
+4 -2
View File
@@ -26,8 +26,10 @@ var builtinRules = []builtinRule{
{name: "Go panic", query: `{namespace=~".+"} |= "panic:"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "JVM OutOfMemoryError", query: `{namespace=~".+"} |~ "(^|[^a-zA-Z])OutOfMemoryError"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "StackOverflowError", query: `{namespace=~".+"} |= "StackOverflowError"`, windowMin: 15, threshold: 0, cooldown: 60},
{name: "Kubernetes 节点 NotReady", query: `{app="kube-state-metrics"} |= "NotReady"`, windowMin: 15, threshold: 0, cooldown: 120},
{name: "容器 OOMKilled", query: `{app=~"kube-state-metrics|event-exporter"} |= "OOMKilled"`, windowMin: 15, threshold: 0, cooldown: 120},
// 注意:这里**只放日志类告警**。
// 「节点 NotReady」「容器 OOMKilled」属于基础设施指标监控(kube-state-metrics /
// Prometheus)的职责,用日志去匹配这些字符串既不可靠(日志没开或轮转就漏),
// 又与日志平台的定位混淆。这类告警请交给 Prometheus + Alertmanager。
// —— 突增类:超过基线阈值才报 ——
{name: "应用错误过多(error > 1000)", query: `{namespace=~".+"} |~ "(?i)\berror\b"`, windowMin: 15, threshold: 1000, cooldown: 60},
{name: "ingress-nginx 5xx 错误", query: `{app=~"ingress-nginx|nginx-ingress-controller"} |~ " (50[0-4]|5[2-9][0-9]) `, windowMin: 15, threshold: 100, cooldown: 60},
+4 -1
View File
@@ -417,7 +417,10 @@ const (
// 日志量统计最多统计 7 天;更长跨度请分段查(前端会提示缩小范围)
maxHistogramRange = 7 * 24 * time.Hour
// 重查询(histogram/metrics)单独的更短超时,避免长时间占用 CPU
heavyQueryTimeout = 30 * time.Second
// 90s:实测 astralx(约 370GB/天)15 分钟窗口单条 count_over_time
// exec 约 29s、外加排队时间,30s 必超时 → 分析页 502。
// 索引类查询(index/volume_range、index/stats)不受此限制,仍是毫秒级。
heavyQueryTimeout = 90 * time.Second
// 全局并发重查询上限。取 4 而不是 2:
// 日志分析页一次会并发 5 条 count_over_time,限制 2 会让大部分请求拿不到令牌。
maxConcurrentHeavyQueries = 4
+44 -1
View File
@@ -345,6 +345,45 @@ func DeleteLogSource(id uint) error {
return fmt.Errorf("数据源不存在")
}
// reachableEndpoint 把部署记录里的访问地址转换成「平台侧能真正连上」的地址。
//
// 主机模式创建部署时存的是容器的监听地址(0.0.0.0:3100 / [::]:3100),
// 那是容器内部视角,平台进程在别的机器/集群里,用它必然 connection refused。
// 换成该主机的真实 IP 再返回;主机信息缺失时原样返回(至少不会更差)。
func reachableEndpoint(d *models.LokiDeployment) string {
endpoint := strings.TrimSpace(d.Endpoint)
if endpoint == "" {
return endpoint
}
if !strings.Contains(endpoint, "0.0.0.0") && !strings.Contains(endpoint, "[::]") {
return endpoint
}
hostIP := ""
if d.Host != nil {
hostIP = strings.TrimSpace(d.Host.HostIP)
}
if hostIP == "" {
// 没带 Host 关联时回库查一次,避免调用方漏预加载
db := repository.GetDB()
if db != nil && d.HostID != nil {
db.RLock()
for i := range db.Hosts {
if db.Hosts[i].ID == *d.HostID {
hostIP = strings.TrimSpace(db.Hosts[i].HostIP)
break
}
}
db.RUnlock()
}
}
if hostIP == "" {
return endpoint
}
endpoint = strings.Replace(endpoint, "0.0.0.0", hostIP, 1)
endpoint = strings.Replace(endpoint, "[::]", hostIP, 1)
return endpoint
}
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host→url;k8s→cluster-svc)。
// 已存在同名数据源时:平台托管的自动刷新连接信息,用户手填的不覆盖。
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
@@ -360,7 +399,11 @@ func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
}
if d.Mode == "host-binary" || d.Mode == "host-docker" {
target.TargetType = "url"
target.URL = d.Endpoint
// 主机模式部署的 endpoint 记的是**容器监听地址**(0.0.0.0:3100),
// 直接拿来当数据源 URL 会得到 dial tcp 0.0.0.0:3100: connection refused ——
// 平台进程不在那台主机上,0.0.0.0 只在本机有意义。
// 这里换成主机真实 IP,平台才能连上。
target.URL = reachableEndpoint(d)
target.ClusterID = nil
}