Files
LokiStack/backend/services/collector_templates.go
opsx 5e59347c40 feat(settings,analysis): 日志保留时间可配(发送历史保留 7 天)+ 分析页手动刷新;修复我引入的 RWMutex 死锁
=== 0. 【严重】修复上一版引入的死锁(平台写路径全卡死)===
上一版把保留期读取写成 `GetRetentionSettings()`(内部 db.RLock()),
却在 `db.Lock()` 已持有的情况下调用(recordDelivery / RecordAudit 的裁剪路径)。
Go 的 sync.RWMutex **不可重入** → 第一次写投递历史/审计日志就永久死锁。
现象:登录 28 秒返回空(登录也要写审计)、所有写接口挂住。
go build / go vet / 之前的单测(不触发写路径)全都查不出来。
修法:
  - 拆出 retentionFromDBLocked(db) / alertDeliveryRetentionLocked(db) /
    auditLogRetentionLocked(db),调用方已持锁时用无锁版本
  - upsertSettingLocked 里**不能**用 db.NextID()(内部 d.mu.Lock(),同样死锁),
    改为锁内自算 max(id)+1
  - 新增 retention_test.go:持写锁调用裁剪函数,2 秒超时未返回即判死锁
实测修复后:登录 103ms(原 28000ms)、panic/deadlock 命中 0。

=== 1. 平台数据保留期可配(系统设置 → 通用配置 → 日志保留时间)===
新增 GET/PUT /api/v1/settings/retention:
  alertDeliveryDays  告警发送历史保留天数,默认 7(原写死"最近 50 条")
  auditLogDays       审计日志保留天数,默认 90(原写死常量)
保存后立即按新策略裁剪一次,不必等下次触发。
校验:发送历史 1~365 天、审计 1~3650 天,超出返回明确中文原因(不是 500)。
注意与「Loki 日志保留期」区分:那个是部署级配置(组件部署 → 保留策略)。

=== 2. 发送历史:50 条硬上限 → 保留 7 天 ===
后端 Deliveries 接口原来写死 `len(out) < 50`,现在按保留期取(响应上限 500 可调),
标题改为「发送历史(保留 N 天)」,N 从设置里读。

=== 3. 菜单「日志服务」→「组件部署」===

=== 4. 分析页:去掉「全部命名空间」+ 改手动刷新 ===
- 移除「全部命名空间」选项:{namespace=~".+"} 要扫全集群(约 370GB/天),
  一次分析几十秒,得不偿失;历史值 __all__ 也会被忽略并回退到策略/第一个 ns
- 筛选改为「选择值 / 已生效值」分离:切换命名空间或时间范围**不再自动查询**,
  只把刷新按钮点亮并提示「筛选条件已改,点刷新生效」;
  点一次才应用并取数。
  原因:一次分析要跑多条 count_over_time,切一下下拉就重查一轮,
  快速连点会把 Loki 打满(并发护栏 429),还拖慢别人。

=== 实测(9739c7c092e7)===
  登录 103ms / token 273 字符
  GET retention → {alertDeliveryDays:7, auditLogDays:90}
  PUT 0 天 → 400「告警发送历史保留天数需在 1~365 之间」
  平台日志 panic/deadlock 命中 0
  go test ./... 全绿(含 2 个新死锁回归用例)
2026-10-29 04:16:52 +00:00

491 lines
15 KiB
Go
Raw Permalink 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 (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
)
// PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath)
// 生产级配置:持久化位置文件、批处理、重试、CRI日志格式解析
// promtailExternalLabels 组并 cluster 标识与用户自定义标签(JSON)为 external_labels YAML 行
// promtailExternalLabels 生成 external_labels 的子项。
// indent 必须与该模板里 `external_labels:` 的层级匹配:
// - 主机模板(4 空格)→ 子项 6 空格
// - DaemonSet 模板(6 空格)→ 子项 8 空格
//
// 早先写死 8 空格,主机模式一旦配了标签就会生成非法 YAML,Promtail 直接装不上。
func promtailExternalLabels(cluster string, labelsJSON string, indent string) string {
lines := []string{fmt.Sprintf("%scluster: %s", indent, yamlScalar(cluster))}
if labelsJSON != "" {
var m map[string]string
if err := json.Unmarshal([]byte(labelsJSON), &m); err == nil {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
lines = append(lines, fmt.Sprintf("%s%s: %s", indent, k, yamlScalar(m[k])))
}
}
}
return strings.Join(lines, "\n")
}
// isCRILogPath 判断路径是否属于容器运行时日志(K8s pod 日志或 docker 容器日志)。
// 只有这类日志才是 CRI 格式,才能用 cri 阶段解析。
func isCRILogPath(logPath string) bool {
p := strings.ToLower(logPath)
return strings.Contains(p, "/pods/") ||
strings.Contains(p, "/containers/") ||
strings.Contains(p, "/docker/")
}
// PromtailDaemonsetYAML DaemonSet 模式纯 YAML 安装(SA/RBAC/ConfigMap/DaemonSet),
// 经 Kubernetes API 直接提交,后端无需任何额外二进制。
// relabel 体系与主流 Promtail 部署惯例对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
// sanitizePromLabel 把 K8s 标签名转成 Prometheus/Promtail 的 meta label 片段。
//
// k8s.kuboard.cn/name → k8s_kuboard_cn_name
// app.kubernetes.io/name → app_kubernetes_io_name
func sanitizePromLabel(name string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(name) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return b.String()
}
// promtailAppSourceLabel 把用户配置的 AppLabelSource 转成 Promtail 的 meta label 名。
// 返回空串表示「用默认的四级回退链」。
func promtailAppSourceLabel(src string) string {
src = strings.TrimSpace(src)
if src == "" || src == "controller_name" {
return ""
}
if strings.HasPrefix(src, "label:") {
src = strings.TrimPrefix(src, "label:")
}
// 已经是 __meta_ 开头就直接用
if strings.HasPrefix(src, "__meta_kubernetes_") {
return src
}
return "__meta_kubernetes_pod_label_" + sanitizePromLabel(src)
}
// promtailAppIncludeFilter 生成 app 白名单的 keep 规则(为空则不限)。
func promtailAppIncludeFilter(apps []string) string {
clean := make([]string, 0, len(apps))
for _, a := range apps {
if a = strings.TrimSpace(a); a != "" {
clean = append(clean, regexp.QuoteMeta(a))
}
}
if len(clean) == 0 {
return ""
}
return fmt.Sprintf("\n - source_labels: [app]\n regex: ^(%s)$\n action: keep",
strings.Join(clean, "|"))
}
// promtailNamespaceFilter 生成 kubernetes_sd_configs 的命名空间白名单。
// namespaces 为空 → 返回空串(采集全集群所有命名空间)。
func promtailNamespaceFilter(namespaces []string) string {
clean := make([]string, 0, len(namespaces))
for _, n := range namespaces {
if n = strings.TrimSpace(n); n != "" {
clean = append(clean, n)
}
}
if len(clean) == 0 {
return ""
}
var b strings.Builder
b.WriteString(" namespaces:\n names:\n")
for _, n := range clean {
b.WriteString(" - ")
b.WriteString(n)
b.WriteString("\n")
}
return b.String()
}
func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string, targetNamespaces []string, appLabelSource string, appInclude []string) string {
promtailImage := ResolveImageFor("promtail", "grafana/promtail", "3.4.0")
extLabels := promtailExternalLabels(clusterName, labelsJSON, " ")
nsFilter := promtailNamespaceFilter(targetNamespaces)
// 自定义 app 标签来源(用户可用自己注入的私有标签),插到回退链最前面
appSourceLine := ""
if lbl := promtailAppSourceLabel(appLabelSource); lbl != "" {
appSourceLine = " - " + lbl + "\n"
}
appIncludeFilter := promtailAppIncludeFilter(appInclude)
return fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
metadata:
name: promtail
namespace: %s
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: loki-promtail
rules:
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: loki-promtail
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: loki-promtail
subjects:
- kind: ServiceAccount
name: promtail
namespace: %s
---
apiVersion: v1
kind: ConfigMap
metadata:
name: promtail
namespace: %s
data:
promtail.yaml: |
server:
http_listen_port: 3101
grpc_listen_port: 0
positions:
filename: /run/promtail/positions.yaml
clients:
- url: %s/loki/api/v1/push
batchwait: 1s
batchsize: 1048576
backoff_config:
min_period: 500ms
max_period: 5m
max_retries: 10
external_labels:
%s
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
%s
pipeline_stages:
- cri: {}
- multiline:
firstline: '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}[+\\-]\\d{2}:\\d{2}'
max_wait_time: 3s
max_lines: 500
- timestamp:
source: time
format: RFC3339Nano
relabel_configs:
# 跳过已终止的 Pod(Evicted / Failed / Succeeded)。
# 实证:某服务残留 387 个 Evicted Pod,日志文件还在节点上,
# Promtail 会把它们的历史异常堆栈全量采回来,导致 15 分钟 27 万条的假象,
# 也会污染 Loki 存储和查询结果。
- source_labels: [__meta_kubernetes_pod_phase]
regex: (Succeeded|Failed)
action: drop
# 从 controller name 提取 deployment 名(去掉 replicaset 哈希)
- source_labels: [__meta_kubernetes_pod_controller_name]
regex: ([0-9a-z-.]+?)(-[0-9a-f]{8,10})?
action: replace
target_label: __tmp_controller_name
# app 四级回退 —— 全部基于 Kubernetes 原生元数据,跨集群通用:
# 1) app.kubernetes.io/name ← K8s 标准标签约定
# 2) app ← 最常见的简写
# 3) controller name 去 hash ← Pod 的 owner(ReplicaSet) → Deployment 名
# __meta_kubernetes_pod_controller_name 是
# Promtail 从 K8s API 直接拿的,任何集群都有,
# 不依赖任何第三方工具注入的标签
# 4) pod name ← 最后兜底,保证 app 永不为空
#
# 不使用 k8s.kuboard.cn/name 之类的平台私有标签:那只是 Kuboard 管理的集群才有,
# 换其他方式部署的集群就不存在,会破坏通用性。
# 如集群确实有私有规范标签,可在「采集器 → 编辑」里配置 AppLabelSource,
# 它会作为最高优先级插到下面列表最前(其余仍兜底)。
- source_labels:
%s - __meta_kubernetes_pod_label_app_kubernetes_io_name
- __meta_kubernetes_pod_label_app
- __tmp_controller_name
- __meta_kubernetes_pod_name
regex: ^;*([^;]+)(;.*)?$
action: replace
target_label: app
# instance:app.kubernetes.io/instance → instance
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance, __meta_kubernetes_pod_label_instance]
regex: ^;*([^;]+)(;.*)?$
action: replace
target_label: instance
# component:app.kubernetes.io/component → component
- source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component, __meta_kubernetes_pod_label_component]
regex: ^;*([^;]+)(;.*)?$
action: replace
target_label: component
- action: replace
source_labels: [__meta_kubernetes_pod_node_name]
target_label: node_name
- action: replace
source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- action: replace
source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- action: replace
source_labels: [__meta_kubernetes_pod_container_name]
target_label: container
# job: namespace/app(必须放在 app 之后,否则取到的是未回退的空值)
- action: replace
replacement: $1
separator: /
source_labels: [namespace, app]
target_label: job
- action: replace
replacement: /var/log/pods/*$1/*.log
separator: /
source_labels: [__meta_kubernetes_pod_uid, __meta_kubernetes_pod_container_name]
target_label: __path__%s
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: promtail
namespace: %s
labels:
app: promtail
spec:
selector:
matchLabels:
app: promtail
template:
metadata:
labels:
app: promtail
spec:
serviceAccountName: promtail
tolerations:
- effect: NoSchedule
operator: Exists
containers:
- name: promtail
image: %s
imagePullPolicy: IfNotPresent
args: ["-config.file=/etc/promtail/promtail.yaml"]
env:
- name: HOSTNAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
ports:
- name: http-metrics
containerPort: 3101
readinessProbe:
httpGet:
path: /ready
port: http-metrics
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 5
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: config
mountPath: /etc/promtail
- name: run
mountPath: /run/promtail
- name: pods
mountPath: /var/log/pods
readOnly: true
- name: containers
mountPath: /var/lib/docker/containers
readOnly: true
volumes:
- name: config
configMap:
name: promtail
- name: run
hostPath:
path: /run/promtail
- name: pods
hostPath:
path: /var/log/pods
- name: containers
hostPath:
path: /var/lib/docker/containers
`, ns, ns, ns, lokiURL, extLabels, nsFilter, appSourceLine, appIncludeFilter, ns, promtailImage)
}
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
// Sidecar 已明确知道应用与命名空间,无需 Kubernetes 元数据过滤器。
func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string {
return fmt.Sprintf(`[SERVICE]
Flush 1
Log_Level info
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
storage.path /tmp/flb-storage/
storage.sync normal
storage.checksum off
storage.backlog.mem_limit 5M
Health_Check On
HC_Errors_Count 5
HC_Retry_Failure_Count 5
HC_Period 60
[INPUT]
Name tail
Path %s
Tag app.%s
Refresh_Interval 5
Mem_Buf_Limit 10MB
Skip_Long_Lines On
Read_from_Head True
DB /tmp/flb.db
DB.Sync Normal
Buffer_Max_Size 1MB
storage.type filesystem
[FILTER]
Name modify
Match app.*
Add namespace %s
Add app %s
Add source fluent-bit
[OUTPUT]
Name loki
Match *
Host %s
Port %s
Labels job=fluent-bit, app=%s, namespace=%s
Line_Format json
Retry_Limit 10
`, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace)
}
// FluentbitConfigMapYAML 为每个采集策略生成独立 ConfigMap,避免同命名空间的 Sidecar 相互覆盖。
func FluentbitConfigMapYAML(conf, namespace, name string) string {
return fmt.Sprintf(`apiVersion: v1
kind: ConfigMap
metadata:
name: %s
namespace: %s
data:
fluent-bit.conf: |
%s
`, name, namespace, indentBlock(conf, " "))
}
// SidecarPatchYAML 将业务容器与 Fluent Bit 挂到同一日志卷和目录。
func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string {
volume := ""
if mount.createVolume {
volume = fmt.Sprintf(" - name: %s\n emptyDir: {}\n", mount.volumeName)
}
appMount := ""
if mount.mountApplication {
appMount = fmt.Sprintf(" - name: %s\n volumeMounts:\n - name: %s\n mountPath: %q\n", mount.containerName, mount.volumeName, mount.mountPath)
}
subPath := ""
if mount.subPath != "" {
subPath = fmt.Sprintf(" subPath: %q\n", mount.subPath)
}
return fmt.Sprintf(`spec:
template:
spec:
volumes:
%s - name: fluent-bit-config
configMap:
name: %s
- name: fluent-bit-state
emptyDir: {}
containers:
%s - name: fluent-bit
image: %s
imagePullPolicy: IfNotPresent
ports:
- name: fluent-bit-http
containerPort: 2020
protocol: TCP
volumeMounts:
- name: %s
mountPath: %q
%s readOnly: true
- name: fluent-bit-config
mountPath: /fluent-bit/etc/
- name: fluent-bit-state
mountPath: /tmp
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
livenessProbe:
httpGet:
path: /
port: fluent-bit-http
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: fluent-bit-http
initialDelaySeconds: 5
periodSeconds: 5
`, volume, configMapName, appMount, ResolveImageFor("fluentbit", "fluent/fluent-bit", "5.1.5"), mount.volumeName, mount.mountPath, subPath)
}
// indentBlock 给每行加前缀(ConfigMap 嵌入配置需缩进)
func indentBlock(s, pad string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}
// parseLokiURL 从 http://host:port 提取 host 与 port(Fluent Bit OUTPUT 需要)
func parseLokiURL(endpoint string) (host, port string) {
u := strings.TrimSpace(endpoint)
u = strings.TrimPrefix(u, "http://")
u = strings.TrimPrefix(u, "https://")
if i := strings.Index(u, "/"); i >= 0 {
u = u[:i]
}
if i := strings.LastIndex(u, ":"); i >= 0 {
return u[:i], u[i+1:]
}
return u, "3100"
}