=== 一、astralx 只采集到 2 个 app(核心问题)===
实证:用户的 Deployment Pod 模板只有 k8s.kuboard.cn/name 标签,
没有 app / app.kubernetes.io/name。原模板的 app 只从这两个标签取,
所以 115 个服务的 app 全是空值,Loki 里 app 只剩零星 2 个。
修复:app 四级回退,全部基于 Kubernetes 原生元数据(跨集群通用):
1) app.kubernetes.io/name 标准约定
2) app 常见简写
3) __tmp_controller_name Pod owner(ReplicaSet) 去 hash → Deployment 名
(__meta_kubernetes_pod_controller_name 由
Promtail 直接从 K8s API 读取,任何集群都有,
不依赖 Kuboard/Rancher 等第三方工具注入的标签)
4) pod name 兜底,保证 app 永不为空
同时修复 PromtailHelmValues 模板同样的 app 逻辑,
并修正其 __meta_kubernetes__pod_name 双下划线笔误。
验证:astralx 的 app 值从 2 个 → 73 个。
=== 二、15 分钟 27 万条日志的真相 ===
排查结论:不是重复采集(positions 存 hostPath /run/promtail,Pod 重启不丢)。
真实原因:cex-openapi-gateway 残留 347 个 Evicted + 37 个 Error 的僵尸 Pod,
只有 1 个 Running。这些 Pod 的日志文件仍在 /var/log/pods/ 下,
Promtail 会把它们的历史异常堆栈(Reactor at reactor.core.publisher...)
全量采集,造成 27 万条的假象并污染存储。
修复:relabel_configs 首条加 drop 规则:
- source_labels: [__meta_kubernetes_pod_phase]
regex: (Succeeded|Failed)
action: drop
(两个模板都加)
=== 三、SecretKey 不再下发给前端 ===
安全要求:避免任何登录用户点「显示」按钮就能拿到对象存储 SK。
- StorageConfig.Sanitized(): secretKey 返回空串,仅保留 hasSecretKey 标记
(accessKey 仍明文返回,便于管理员核对用的是哪个 AK)
- 测试连接:StorageTestRequest 新增 id 字段,secretKey 留空时后端用
库里保存的凭据解密补齐(新建存储仍强制填写)
- 前端编辑时 SecretKey 一律留空 = 不修改,placeholder 显示
「已保存 · 留空则不修改」,测试时带上 id
=== 四、采集器支持指定命名空间 ===
- Collector 新增 TargetNamespaces []string,对应 promtail 的
kubernetes_sd_configs.namespaces.names(为空=全集群)
- PromtailDaemonsetYAML 新增 promtailNamespaceFilter 生成白名单
- service.Update 支持 targetNamespaces(兼容 []string 与逗号分隔字符串)
- 前端 CollectorEditDialog 增加「采集命名空间」输入(逗号分隔)
镜像:lghost/lokistack:latest + v0.1 = sha256:05b1505aedae
316 lines
8.3 KiB
Go
316 lines
8.3 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"opsxloki/models"
|
|
"opsxloki/repository"
|
|
"opsxloki/utils"
|
|
)
|
|
|
|
type CollectorService struct{}
|
|
|
|
func NewCollectorService() *CollectorService {
|
|
return &CollectorService{}
|
|
}
|
|
|
|
type CreateCollectorRequest struct {
|
|
Name string
|
|
Type string
|
|
LokiDeploymentID uint
|
|
HostID *uint
|
|
ClusterID *uint
|
|
TargetDeployment string
|
|
Namespace string
|
|
LogPath string
|
|
Labels string
|
|
}
|
|
|
|
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
|
|
func (s *CollectorService) fillAssociations(c *models.Collector) {
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
defer db.RUnlock()
|
|
for i := range db.Deployments {
|
|
if db.Deployments[i].ID == c.LokiDeploymentID {
|
|
d := db.Deployments[i]
|
|
c.LokiDeployment = &d
|
|
break
|
|
}
|
|
}
|
|
if c.HostID != nil {
|
|
for i := range db.Hosts {
|
|
if db.Hosts[i].ID == *c.HostID {
|
|
h := db.Hosts[i]
|
|
c.Host = &h
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if c.ClusterID != nil {
|
|
for i := range db.Clusters {
|
|
if db.Clusters[i].ID == *c.ClusterID {
|
|
cl := db.Clusters[i]
|
|
c.Cluster = &cl
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// generateConfigSnapshot 按类型生成配置快照
|
|
func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
|
|
endpoint := ""
|
|
if c.LokiDeployment != nil {
|
|
endpoint = c.LokiDeployment.Endpoint
|
|
}
|
|
clusterName := "host"
|
|
if c.Cluster != nil && c.Cluster.Name != "" {
|
|
clusterName = c.Cluster.Name
|
|
}
|
|
switch c.Type {
|
|
case "host-promtail":
|
|
return PromtailConfig(endpoint, c.LogPath, "host", c.Labels)
|
|
case "k8s-daemonset":
|
|
return PromtailHelmValues(endpoint, clusterName, c.Labels)
|
|
case "k8s-fluentbit-sidecar":
|
|
host, port := parseLokiURL(endpoint)
|
|
app := c.TargetDeployment
|
|
ns := c.Namespace
|
|
if ns == "" {
|
|
ns = "default"
|
|
}
|
|
return FluentbitConfig(host, port, c.LogPath, app, ns)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collector, error) {
|
|
switch req.Type {
|
|
case "host-promtail", "k8s-daemonset", "k8s-fluentbit-sidecar":
|
|
default:
|
|
return nil, fmt.Errorf("不支持的采集器类型: %s", req.Type)
|
|
}
|
|
if req.LokiDeploymentID == 0 {
|
|
return nil, fmt.Errorf("请选择关联的 Loki 部署")
|
|
}
|
|
if req.Type == "host-promtail" && (req.HostID == nil || *req.HostID == 0) {
|
|
return nil, fmt.Errorf("主机采集器需要选择目标主机")
|
|
}
|
|
if (req.Type == "k8s-daemonset" || req.Type == "k8s-fluentbit-sidecar") && (req.ClusterID == nil || *req.ClusterID == 0) {
|
|
return nil, fmt.Errorf("K8s 采集器需要选择目标集群")
|
|
}
|
|
if req.Type == "k8s-fluentbit-sidecar" && req.TargetDeployment == "" {
|
|
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
|
|
}
|
|
|
|
refType := "cluster"
|
|
if req.Type == "host-promtail" {
|
|
refType = "host"
|
|
}
|
|
|
|
collector := models.Collector{
|
|
ID: repository.GetDB().NextID("collectors"),
|
|
Name: req.Name,
|
|
Type: req.Type,
|
|
LokiDeploymentID: req.LokiDeploymentID,
|
|
RefType: refType,
|
|
HostID: req.HostID,
|
|
ClusterID: req.ClusterID,
|
|
TargetDeployment: req.TargetDeployment,
|
|
Namespace: req.Namespace,
|
|
LogPath: req.LogPath,
|
|
Labels: req.Labels,
|
|
Status: "pending",
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
s.fillAssociations(&collector)
|
|
if collector.LokiDeployment == nil {
|
|
return nil, fmt.Errorf("关联的 Loki 部署不存在")
|
|
}
|
|
collector.Config = s.generateConfigSnapshot(&collector)
|
|
|
|
db := repository.GetDB()
|
|
db.Lock()
|
|
db.Collectors = append(db.Collectors, collector)
|
|
db.Unlock()
|
|
db.RequestSave()
|
|
|
|
go s.Install(collector.ID)
|
|
return &collector, nil
|
|
}
|
|
|
|
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
var found *models.Collector
|
|
for i := range db.Collectors {
|
|
if db.Collectors[i].ID == id {
|
|
c := db.Collectors[i]
|
|
found = &c
|
|
break
|
|
}
|
|
}
|
|
db.RUnlock()
|
|
if found == nil {
|
|
return nil, utils.ErrNotFound
|
|
}
|
|
s.fillAssociations(found)
|
|
return found, nil
|
|
}
|
|
|
|
func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) {
|
|
db := repository.GetDB()
|
|
db.RLock()
|
|
total := int64(len(db.Collectors))
|
|
start := (page - 1) * pageSize
|
|
if start < 0 || start >= len(db.Collectors) {
|
|
db.RUnlock()
|
|
return []models.Collector{}, total, nil
|
|
}
|
|
end := start + pageSize
|
|
if end > len(db.Collectors) {
|
|
end = len(db.Collectors)
|
|
}
|
|
result := make([]models.Collector, end-start)
|
|
copy(result, db.Collectors[start:end])
|
|
db.RUnlock()
|
|
for i := range result {
|
|
s.fillAssociations(&result[i])
|
|
}
|
|
return result, total, nil
|
|
}
|
|
|
|
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
|
|
db := repository.GetDB()
|
|
db.Lock()
|
|
defer db.Unlock()
|
|
for i := range db.Collectors {
|
|
if db.Collectors[i].ID == id {
|
|
if v, ok := updates["name"].(string); ok && v != "" {
|
|
db.Collectors[i].Name = v
|
|
}
|
|
if v, ok := updates["status"].(string); ok && v != "" {
|
|
db.Collectors[i].Status = v
|
|
}
|
|
if v, ok := updates["statusMessage"].(string); ok {
|
|
db.Collectors[i].StatusMessage = v
|
|
}
|
|
if v, ok := updates["logPath"].(string); ok {
|
|
if v != db.Collectors[i].LogPath {
|
|
db.Collectors[i].StatusMessage = "采集路径已保存,点击部署以应用新配置"
|
|
}
|
|
db.Collectors[i].LogPath = v
|
|
}
|
|
if v, ok := updates["labels"].(string); ok {
|
|
db.Collectors[i].Labels = v
|
|
}
|
|
// targetNamespaces: 采集哪些命名空间([]string;空=全集群)
|
|
if v, ok := updates["targetNamespaces"]; ok {
|
|
var nss []string
|
|
switch t := v.(type) {
|
|
case []interface{}:
|
|
for _, item := range t {
|
|
if s, ok := item.(string); ok {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
nss = append(nss, s)
|
|
}
|
|
}
|
|
}
|
|
case []string:
|
|
for _, s := range t {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
nss = append(nss, s)
|
|
}
|
|
}
|
|
case string:
|
|
// 兼容 "astralx,log" 这种逗号分隔写法
|
|
for _, s := range strings.Split(t, ",") {
|
|
if s = strings.TrimSpace(s); s != "" {
|
|
nss = append(nss, s)
|
|
}
|
|
}
|
|
}
|
|
if len(nss) != len(db.Collectors[i].TargetNamespaces) {
|
|
db.Collectors[i].StatusMessage = "采集命名空间已保存,点击部署以应用新配置"
|
|
} else {
|
|
for j := range nss {
|
|
if nss[j] != db.Collectors[i].TargetNamespaces[j] {
|
|
db.Collectors[i].StatusMessage = "采集命名空间已保存,点击部署以应用新配置"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
db.Collectors[i].TargetNamespaces = nss
|
|
}
|
|
db.Collectors[i].UpdatedAt = time.Now()
|
|
db.RequestSave()
|
|
return nil
|
|
}
|
|
}
|
|
return utils.ErrNotFound
|
|
}
|
|
|
|
func (s *CollectorService) Delete(id uint) error {
|
|
collector, err := s.Get(id)
|
|
if err != nil {
|
|
return utils.ErrNotFound
|
|
}
|
|
db := repository.GetDB()
|
|
db.Lock()
|
|
for i, c := range db.Collectors {
|
|
if c.ID == id {
|
|
db.Collectors = append(db.Collectors[:i], db.Collectors[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
db.Unlock()
|
|
db.RequestSave()
|
|
// 异步卸载(用拷贝的 collector,避免删除后取不到)
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// Install 异步部署采集器
|
|
func (s *CollectorService) Install(id uint) {
|
|
collector, err := s.Get(id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if collector.LokiDeployment == nil {
|
|
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": "关联的 Loki 不存在"})
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
|
defer cancel()
|
|
if err := NewCollectorDeployer(collector, collector.LokiDeployment, nil).Install(ctx); err != nil {
|
|
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()})
|
|
return
|
|
}
|
|
s.Update(id, map[string]interface{}{"status": "active", "statusMessage": "采集器已部署"})
|
|
}
|
|
|
|
// Uninstall 异步卸载采集器
|
|
func (s *CollectorService) Uninstall(id uint) {
|
|
collector, err := s.Get(id)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if collector.LokiDeployment == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
|
|
s.Update(id, map[string]interface{}{"status": "pending", "statusMessage": "采集器已卸载"})
|
|
}
|