fix(promtail): 修 Sprintf 缺参导致清单非法 + Ingress 模板固定命名空间 + 资源名去 opsxloki

=== 一、致命 bug:Promtail 清单第 10 个 %s 缺参数(导致日志采集中断)===
go vet 报:"format %s reads arg #10, but call has 9 args"。
模板里 DaemonSet 的 `namespace: %s`(第 9 个)和 `image: %s`(第 10 个)
是两个独立占位符,但 Sprintf 只传了 9 个参数,第 10 个变成 %!s(MISSING),
整个多文档 YAML 解析失败:
  "解析 Kubernetes YAML: yaml: line 23: found character that cannot start any token"

而 installDaemonset 是「先删 daemonset/configmap/serviceaccount 再 apply」,
所以每次失败都会把上一份 Promtail 删掉却不重建 → 日志采集中断。

修复:
1) Sprintf 补上 DaemonSet 命名空间参数(第 9 位)
2) 新增 validateManifest():在删旧资源之前先本地解析校验清单,
   校验不过直接中止、不碰任何现有资源 —— 让模板错误永远不会再打断采集
3) 新增回归测试 promtail_manifest_test.go:用平台同一套
   YAMLOrJSONDecoder 解析 3 种参数组合,断言各解析出 5 个对象且无 TAB

=== 二、快捷 LogQL:Ingress 访问日志不再跟随所选命名空间 ===
原实现:next = applyLabelFilter(next,'namespace', namespace.trim() || 'ingress-nginx')
→ 选了 monitor 就生成 {namespace="monitor"} |= "HTTP/",查不到任何日志
  (ingress controller 的 Pod 只在 ingress-nginx 命名空间里)

修复:ingress 模板独立处理
  - 固定用 ingress controller 命名空间(ingress-nginx,缺失则回退 ingress)
  - 不叠加当前选中的 app / pod 过滤(那些属于业务服务)
  - 结果是 {namespace="ingress-nginx"} |= "HTTP/"

=== 三、K8s 资源名去掉 opsxloki ===
ClusterRole / ClusterRoleBinding:opsxloki-promtail → loki-promtail
- PromtailDaemonsetYAML 里 3 处 name 全部改名
- uninstallDaemonset 同步改名,并保留对旧名 opsxloki-promtail 的兼容清理,
  避免升级后留下孤儿 ClusterRole
- 集群侧已实测:新建 loki-promtail,旧的 opsxloki-promtail 已删除

保留的 opsxloki(有兼容性约束,改动会丢数据/掉登录):
- Go module 导入路径 opsxloki/xxx(149 处,纯内部包名)
- 数据文件 opsxloki.json(改了读不到历史数据)
- 前端 localStorage key opsxloki.auth.* / opsxloki.theme.* 等(改了在线用户掉登录)
- Dockerfile 里的 opsxloki 系统用户/二进制名

镜像:lghost/lokistack:latest + v0.1 = sha256:4a5590580cbb
This commit is contained in:
opsx committed 2026-10-27 04:16:55 +00:00
1 parent 41633f0b38
commit 02d1bd87a7
5 files changed
+137 -10

No files matched your search

+17 -3
View File
@@ -200,12 +200,20 @@ func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint strin
clusterName = cluster.Name
}
manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels,
d.collector.TargetNamespaces, d.collector.AppLabelSource, d.collector.AppInclude)
// 关键顺序:先本地校验清单,再删旧资源。
// 否则模板有误时会出现「旧 DaemonSet 已删、新的建不出来」→ 日志采集直接中断。
if err := validateManifest(manifest); err != nil {
d.log("error", fmt.Sprintf("生成的 Promtail 清单无效,已中止且未改动现有资源: %v", err), "")
return fmt.Errorf("Promtail 清单校验失败(现有采集器未受影响): %w", err)
}
// 删除历史残留并通过 Kubernetes API 提交资源。
for _, kind := range []string{"daemonset", "configmap", "serviceaccount"} {
_ = deleteK8sResource(ctx, cluster, kind, ns, "promtail")
}
manifest := PromtailDaemonsetYAML(ns, endpoint, clusterName, d.collector.Labels,
d.collector.TargetNamespaces, d.collector.AppLabelSource, d.collector.AppInclude)
if err := d.applyYAML(ctx, nil, manifest, "promtail"); err != nil {
return err
}
@@ -245,7 +253,13 @@ func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
if ns == "" {
ns = "log"
}
for _, item := range [][2]string{{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"}, {"clusterrole", "opsxloki-promtail"}, {"clusterrolebinding", "opsxloki-promtail"}} {
// clusterrole / clusterrolebinding 用 loki-promtail;
// 旧版本曾用 opsxloki-promtail,这里一并清理以免升级后留下孤儿资源。
for _, item := range [][2]string{
{"daemonset", "promtail"}, {"configmap", "promtail"}, {"serviceaccount", "promtail"},
{"clusterrole", "loki-promtail"}, {"clusterrolebinding", "loki-promtail"},
{"clusterrole", "opsxloki-promtail"}, {"clusterrolebinding", "opsxloki-promtail"},
} {
if err := deleteK8sResource(ctx, cluster, item[0], ns, item[1]); err != nil {
return err
}
+4 -4
View File
@@ -287,7 +287,7 @@ metadata:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: opsxloki-promtail
name: loki-promtail
rules:
- apiGroups: [""]
resources: ["pods", "nodes"]
@@ -296,11 +296,11 @@ rules:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: opsxloki-promtail
name: loki-promtail
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: opsxloki-promtail
name: loki-promtail
subjects:
- kind: ServiceAccount
name: promtail
@@ -482,7 +482,7 @@ spec:
- name: containers
hostPath:
path: /var/lib/docker/containers
`, ns, ns, ns, lokiURL, extLabels, nsFilter, appSourceLine, promtailImage, appIncludeFilter)
`, ns, ns, ns, lokiURL, extLabels, nsFilter, appSourceLine, appIncludeFilter, ns, promtailImage)
}
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
+33
View File
@@ -39,6 +39,39 @@ func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace str
return err
}
// validateManifest 只做本地 YAML 解析校验,不访问集群。
//
// 用途:采集器 install 流程是「先删旧资源再 apply」,一旦模板生成有误
// (例如 fmt.Sprintf 占位符错位产生 %!s(MISSING)),就会出现
// 「旧的已删、新的没建成」→ 日志采集中断。所以在删之前先跑一遍解析。
func validateManifest(manifest string) error {
decoder := yamlutil.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 4096)
count := 0
for {
var raw map[string]interface{}
if err := decoder.Decode(&raw); err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("解析 Kubernetes YAML: %w", err)
}
if len(raw) == 0 {
continue
}
obj := &unstructured.Unstructured{Object: raw}
if obj.GetKind() == "" || obj.GetAPIVersion() == "" {
return fmt.Errorf("清单缺少 apiVersion/kind")
}
if obj.GetName() == "" {
return fmt.Errorf("Kubernetes %s 缺少 metadata.name", obj.GetKind())
}
count++
}
if count == 0 {
return fmt.Errorf("清单为空")
}
return nil
}
// applyManifest 使用 API Server 的 server-side apply,保留已有 YAML 模板与字段所有权。
func applyManifest(ctx context.Context, cluster *models.Cluster, manifest string) error {
cfg, err := clusterRESTConfig(cluster, 30*time.Second)
@@ -0,0 +1,66 @@
package services
import (
"bytes"
"io"
"strings"
"testing"
"k8s.io/apimachinery/pkg/util/yaml"
)
// 复现平台 install 时的 YAML 解析,定位非法 token
func TestPromtailManifestParses(t *testing.T) {
cases := []struct {
name string
labels string
ns []string
src string
inc []string
}{
{"空labels+空ns+空来源", "", nil, "", nil},
{"正常labels", `{"cluster":"pre-k8s"}`, []string{"astralx", "log"}, "k8s.kuboard.cn/name", []string{"cex-spot-api"}},
{"空labels但带ns", "", []string{"astralx"}, "controller_name", []string{"a", "b"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out := PromtailDaemonsetYAML("log", "http://loki.log.svc.cluster.local:3100", "pre-k8s", c.labels, c.ns, c.src, c.inc)
// 1) 扫控制字符
for i, line := range strings.Split(out, "\n") {
if strings.ContainsRune(line, '\t') {
t.Errorf("line %d contains TAB: %q", i+1, line)
}
}
// 2) 用平台同一套 decoder 解析
dec := yaml.NewYAMLOrJSONDecoder(bytes.NewReader([]byte(out)), 4096)
n := 0
for {
var raw map[string]interface{}
if err := dec.Decode(&raw); err == io.EOF {
break
} else if err != nil {
lines := strings.Split(out, "\n")
// 打印报错行附近
var from, to int
if len(lines) > 30 {
from, to = 1, 30
} else {
from, to = 1, len(lines)
}
for i := from - 1; i < to && i < len(lines); i++ {
t.Logf("%3d| %q", i+1, lines[i])
}
t.Fatalf("YAML decode failed: %v", err)
}
n++
}
t.Logf("解析出 %d 个对象", n)
if n < 4 {
t.Errorf("期望 >=4 个对象(SA/ClusterRole/CRB/ConfigMap/DaemonSet),实际 %d", n)
}
})
}
}
@@ -6,6 +6,9 @@ import { applyLabelFilter, extractSelector } from '../query-utils'
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
/** Ingress 访问日志所在命名空间候选(按顺序取第一个实际存在的) */
const INGRESS_NAMESPACE_CANDIDATES = ['ingress-nginx', 'ingress']
function selectorOf(labels: Record<string, string>) {
const entries = Object.entries(labels).filter(([, value]) => value.trim())
if (entries.length === 0) return ''
@@ -117,15 +120,26 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
}
function generate(template: Template) {
// Ingress 访问日志与当前选中的业务命名空间无关:
// ingress controller 的 Pod 只在它自己的命名空间(ingress-nginx / ingress)里,
// 所以这里固定用该命名空间,并且不叠加 app / pod 过滤(那些属于业务服务)。
// 原实现 `namespace.trim() || 'ingress-nginx'` 会导致选了 monitor 就变成
// {namespace="monitor"} |= "HTTP/",一条都查不到。
if (template === 'ingress') {
const ingressNs =
INGRESS_NAMESPACE_CANDIDATES.find((n) => namespaces.includes(n)) ??
INGRESS_NAMESPACE_CANDIDATES[0]
onQueryChange(`{namespace="${ingressNs}"} |= "HTTP/"`)
return
}
let next = extractSelector(query) || '{}'
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
else if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim())
if (namespace.trim()) next = applyLabelFilter(next, 'namespace', namespace.trim())
if (app.trim()) next = applyLabelFilter(next, appLabel, app.trim())
if (pod.trim()) next = applyLabelFilter(next, 'pod', pod.trim())
if (next === '{}') return
if (template === 'error') next += ' |= "error"'
if (template === 'warn') next += ' |= "warn"'
if (template === 'ingress') next += ' |= "HTTP/"'
onQueryChange(next)
}