feat(collectors): 主机采集器容器优先托管 + 采集器日志查看 + 推送地址纠正

现象
1. 主机采集器只有 systemd 一条路,跨发行版差异大(这轮已踩过 param 被删、日志路径、
   时间戳格式三处);进程存活/开机自启也没有统一保证。
2. 「采集器日志」在主机模式下无法从平台查看,排「起没起来/在报什么错」必须 SSH。
3. 实测发现:Loki 部署把 listenIP 填成 0.0.0.0 时,部署记录 endpoint 是
   http://0.0.0.0:3100,采集器照抄去推 -> connect: connection refused,
   而容器/systemd 都是 running、平台 status=active —— 日志静默丢失。

根因
1. 托管方式写死在代码里(promtail 二进制 + systemd 单元),没有能力探测。
2. 没有对外暴露采集器自身日志的接口。
3. 渲染 promtail 配置时直接用 deployment.Endpoint,没有校验它是不是「可连接的推送地址」;
   另外容器用 bridge 网络,127.0.0.1 在容器里指向容器自身,同机 Loki 也会推失败。

修法
- services/collector_host_runtime.go(新增)
  * detectHostCapabilities:一次 SSH 往返探测 架构/init/systemd/docker可用/开机自启/SELinux/journald
  * chooseHostRunMode:docker 优先 -> systemd 兜底 -> 两者都没有则明确报错(不裸跑进程)
  * installHostDocker:grafana/promtail:3.4.0 容器,--restart=always --memory=256m --cpus=1
    --log-opt max-size=10m,max-file=3,挂 /etc/promtail(:ro) /var/lib/promtail /var/log(:ro)
    (+journald 相关),SELinux Enforcing 时 --security-opt label=disable;
    先 docker run --rm -check-syntax 校验配置,再替换正式配置
  * waitContainerRunning:看 Status + RestartCount,起来就崩立即报错并带回 docker logs
  * hostPushEndpoint:0.0.0.0/:: 换真实主机 IP;容器模式下 127.0.0.1/localhost 也换;
    换不了明确报错
  * CollectorLogs:主机 docker logs / journalctl,K8s DaemonSet Pod 日志、Sidecar fluent-bit 容器日志
- collector_deployer.go:installHost 探测->选模式->渲染配置->分发;systemd 路径从 docker 迁回时
  清理同名容器;卸载时容器与 systemd 两种方式都清(RunMode 对老记录是空的)
- 拉不到镜像(内网无外网)时降级 systemd 二进制托管,其它错误原样抛出
- models/collector.go:新增 runMode 并纳入 Sanitized;services.Update 白名单同步
- 接口 GET /api/v1/collectors/:id/logs?tail=N;前端卡片加「运行方式」+「日志」弹窗
- build.sh:detect_go 的 INFO 走 stderr(原来被打进 GO_CMD,构建必失败)

验证(172.19.16.8 / pre-cex-ops-jenkins,collector id=2)
- 安装后 runMode=docker,容器 Up,RestartPolicy=always,systemd promtail 自动停用
- 推送地址自动纠正为 http://172.19.16.8:3100,Loki 近 10 分钟查到 4919 条
  job=system/job=app、host=pre-cex-ops-jenkins
- docker 垫片模拟「docker 不可用」-> 自动回退 runMode=systemd、unit active+enabled、
  日志接口 source=systemd;移除垫片重装 -> 切回 docker
- 卸载:容器 0 个、unit 文件不存在、/etc/promtail 不存在、runMode 清空;重装恢复 running/0 重启
- K8s 采集器日志接口 source=kubernetes(顺带暴露 429 限流,见后续日志量治理)
- go vet 干净;go test ./... 全绿(新增 chooseHostRunMode / hostPushEndpoint 等 6 组用例);
  tsc -b 通过
- 镜像 lghost/lokistack:latest = :v0.1 = sha256:906a6d5369d7,Pod imageID 一致
This commit is contained in:
hermes-agent committed 2026-10-28 20:01:38 +00:00
1 parent c983d0e3c1
commit 7e3356e32b
16 files changed
+1095 -69

No files matched your search

+197 -57
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"encoding/json"
"errors"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"strings"
@@ -69,8 +70,19 @@ func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
return nil
}
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
// ===== host-promtail:托管方式自动探测(docker 优先,systemd 兜底) =====
// installHost 主机采集器安装入口。
//
// 「进程活着」这件事不该由平台自己扛:平台的职责是「期望状态 + 配置下发 + 健康校验」,
// 存活/开机自启交给成熟的监督者。所以按目标机能力自动选托管方式:
//
// ① 有可用 Docker(docker info 成功)→ 容器(--restart=always,跨发行版抹平差异)
// ② 无 Docker 但有 systemd → 二进制 + systemd 单元
// ③ 两者都没有 → 明确报错,绝不裸跑进程
//
// 第 ③ 条是硬约束:`nohup promtail &` 起的进程主机重启即消失,平台不主动巡检就会
// 静默断采;要补齐就得自己实现存活检测/PID 管理/开机自启,等于重造一个更差的 supervisor。
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
host := d.collector.Host
if host == nil {
@@ -82,57 +94,74 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
}
defer client.Close()
// 检测架构
archOut, _, _ := client.Execute(ctx, "uname -m")
arch := strings.TrimSpace(archOut)
switch arch {
case "x86_64":
arch = "amd64"
case "aarch64", "arm64":
arch = "arm64"
default:
return fmt.Errorf("不支持的主机架构: %s", arch)
caps := detectHostCapabilities(ctx, client)
d.log("info", fmt.Sprintf("目标机探测:架构 %s / init %s / docker %s / SELinux %s",
caps.Arch, caps.InitName, caps.DockerSummary(), caps.SELinux), "")
if err := ensureHostPromtailDirs(ctx, client); err != nil {
return err
}
// 确定 Promtail 版本(优先使用关联的 Loki 版本)
version := "3.0.0"
if d.loki != nil && d.loki.Version != "" {
version = d.loki.Version
// 先定托管方式,再渲染配置:容器模式下回环地址必须换掉(bridge 里 127.0.0.1 指容器自己)
mode, err := chooseHostRunMode(caps)
if err != nil {
return err
}
// 创建必要目录
d.log("info", "准备目录 /opt/promtail /etc/promtail /var/lib/promtail", "")
if _, _, err := client.Execute(ctx, "mkdir -p /opt/promtail /etc/promtail /var/lib/promtail"); err != nil {
return fmt.Errorf("创建 Promtail 目录失败: %w", err)
// 配置生成与上传(写 .new,两种托管方式共用;校验通过才替换正式配置)
conf, err := d.prepareHostConfig(ctx, client, host, endpoint, mode == HostRunModeDocker)
if err != nil {
return err
}
// 下载 Promtail
d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "")
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, arch)
dl := fmt.Sprintf("cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
if _, _, err := client.Execute(ctx, dl); err != nil {
return fmt.Errorf("下载 Promtail 失败: %w", err)
if mode == HostRunModeDocker {
err := d.installHostDocker(ctx, client, conf, caps)
if err == nil {
return nil
}
// 目标机拉不到镜像(内网/无外网)时,systemd 二进制托管是可行的退路;
// 其它错误(配置非法、容器崩溃)必须原样抛出,不能被回退掩盖。
if errors.Is(err, errImagePullFailed) && caps.HasSystemd {
d.log("warn", fmt.Sprintf("容器方式不可用,回退为 systemd 二进制托管:%v", err), "")
return d.installHostSystemd(ctx, client, conf, caps)
}
return err
}
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
d.log("warn", "目标机没有可用的 Docker,回退为 systemd 二进制托管(跨发行版差异更大,建议先装 Docker)", "")
return d.installHostSystemd(ctx, client, conf, caps)
}
// 生成并上传配置
// prepareHostConfig 探测目标机身份并渲染/上传 promtail.yaml.new。
//
// 三件事必须在安装期探测,不能写死:
// - 真实主机名(promtail 不展开 ${HOSTNAME},写死会在 Loki 里留下一堆字面量标签)
// - 系统日志时间戳格式(TencentOS 是 ISO8601,Debian 是传统 syslog)
// - 系统日志路径(Ubuntu 只有 syslog/auth.log,没有 messages)
func (d *CollectorDeployer) prepareHostConfig(ctx context.Context, client *SSHClient, host *models.Host, endpoint string, dockerMode bool) (string, error) {
logPath := d.collector.LogPath
if logPath == "" {
logPath = "/var/log/*.log"
}
// 目标机真实身份与 syslog 格式只能在安装时探测(见 Bug3 / Bug4):
// - host 标签写真实主机名,promtail 不做 ${HOSTNAME} 展开;
// - 系统日志按实际格式(ISO8601 / 传统 syslog)选正则。
// 推送地址纠正:Loki 的 listenIP 填成 0.0.0.0 时 endpoint 是必然连不上的
// http://0.0.0.0:3100,采集器会 running 但一条日志都推不上去。
hostIP := ""
if host != nil {
hostIP = host.HostIP
}
pushEndpoint, err := hostPushEndpoint(endpoint, hostIP, dockerMode)
if err != nil {
return "", err
}
if pushEndpoint != endpoint {
d.log("warn", fmt.Sprintf("Loki 推送地址不可用(%s),已改为 %s —— 建议把 Loki 部署的监听地址改成实际 IP/域名", endpoint, pushEndpoint), "")
}
hostname := detectHostname(ctx, client, host)
syslogFormat := detectSyslogFormat(ctx, client)
d.log("info", fmt.Sprintf("目标机 %s,syslog 格式 %s", hostname, syslogFormat), "")
// 系统日志路径同样必须探测:Ubuntu 只有 syslog/auth.log,没有 messages
sysLogPaths := detectSystemLogPaths(ctx, client)
d.log("info", fmt.Sprintf("系统日志路径:%s", strings.Join(sysLogPaths, ",")), "")
d.log("info", fmt.Sprintf("目标机 %s,syslog 格式 %s,系统日志 %s",
hostname, syslogFormat, strings.Join(sysLogPaths, ",")), "")
conf, err := PromtailConfigForHost(HostPromtailOptions{
Endpoint: endpoint,
Endpoint: pushEndpoint,
Cluster: "host",
LabelsJSON: d.collector.Labels,
Hostname: hostname,
@@ -142,29 +171,127 @@ func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) er
Jobs: d.collector.ScrapeJobs,
})
if err != nil {
return fmt.Errorf("生成 Promtail 配置失败(现有配置未改动): %w", err)
return "", fmt.Errorf("生成 Promtail 配置失败(现有配置未改动): %w", err)
}
d.collector.ResolvedHostname = hostname
d.collector.SyslogFormat = syslogFormat
d.collector.SystemLogPaths = sysLogPaths
// 先写临时文件并做语法校验,通过后才替换正式配置 ——
// 配置坏了会把正在跑的采集干掉,不能直接覆盖。
d.log("info", "上传 Promtail 配置并校验", "")
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml.new << 'PROMTAIL_EOF'\n%s\nPROMTAIL_EOF", conf)); err != nil {
return fmt.Errorf("上传配置失败: %w", err)
// 先写 .new:配置坏了会把正在跑的采集干掉,不能直接覆盖正式配置
d.log("info", "上传 Promtail 配置", "")
if err := uploadDockerFile(ctx, client, promtailConfigPath+".new", conf, "644"); err != nil {
return "", fmt.Errorf("上传配置失败: %w", err)
}
if out, _, err := client.Execute(ctx, "/usr/local/bin/promtail -check-syntax -config.file=/etc/promtail/promtail.yaml.new 2>&1"); err != nil {
// 老版本 promtail 可能没有 -check-syntax:只有明确报配置错误才阻断
return conf, nil
}
// installHostDocker 以容器方式托管采集器(首选路径)。
func (d *CollectorDeployer) installHostDocker(ctx context.Context, client *SSHClient, conf string, caps *HostCapabilities) error {
name := promtailContainerName(d.collector.ID)
image := ResolveImageFor("promtail", "grafana/promtail", DefaultPromtailVersion)
d.log("info", "拉取采集器镜像 "+image, "")
if _, stderr, err := client.Execute(ctx, "docker pull "+image); err != nil {
return fmt.Errorf("%w(目标机需能访问镜像仓库,可先在平台侧配置镜像加速): %v: %s",
errImagePullFailed, err, truncateForMessage(strings.TrimSpace(stderr), 300))
}
// 用容器自己校验配置:3.x 的 -check-syntax 能挡住路径/正则/缩进错误
d.log("info", "校验 Promtail 配置", "")
checkCmd := fmt.Sprintf("docker run --rm -v %s:%s:ro %s -check-syntax -config.file=%s.new 2>&1",
promtailConfigDir, promtailConfigDir, image, promtailConfigPath)
out, _, err := client.Execute(ctx, checkCmd)
if err != nil {
msg := strings.TrimSpace(out)
if strings.Contains(msg, "flag provided but not defined") {
d.log("info", "当前 Promtail 版本不支持 -check-syntax,跳过语法校验", "")
} else {
return fmt.Errorf("Promtail 配置校验未通过(现有采集未改动):%s", truncateForMessage(msg, 600))
}
}
if err := promoteHostConfig(ctx, client); err != nil {
return err
}
// 从 systemd 迁到容器:不先停旧服务就会两边同时采同一批文件(日志重复一倍)
if out, _, err := client.Execute(ctx, "systemctl is-active "+promtailServiceName+" 2>/dev/null"); err == nil && strings.TrimSpace(out) == "active" {
client.Execute(ctx, "systemctl stop "+promtailServiceName+" 2>/dev/null; systemctl disable "+promtailServiceName+" 2>/dev/null")
d.log("info", "已停止旧的 systemd promtail 服务(改为容器托管,避免重复采集)", "")
}
client.Execute(ctx, "docker rm -f "+name+" >/dev/null 2>&1 || true")
mounts := []string{
"-v " + promtailConfigDir + ":" + promtailConfigDir + ":ro",
"-v " + promtailPositionsDir + ":" + promtailPositionsDir,
"-v /var/log:/var/log:ro",
}
if caps.HasJournald {
// 采 systemd journal 时需要(Ubuntu 最小安装无 rsyslog 的场景)
mounts = append(mounts,
"-v /run/log/journal:/run/log/journal:ro",
"-v /etc/machine-id:/etc/machine-id:ro")
}
// SELinux Enforcing 时 bind mount 会被拒绝(容器里读不到 /var/log);
// 采集器容器不承载多租户隔离,直接关标签检查,比给宿主机目录重新打标签安全。
secOpt := ""
if strings.EqualFold(caps.SELinux, "Enforcing") {
secOpt = " --security-opt label=disable"
}
runCmd := fmt.Sprintf(
"docker run -d --name %s --restart=always --memory=256m --cpus=1 "+
"--log-opt max-size=10m --log-opt max-file=3%s %s %s -config.file=%s",
name, secOpt, strings.Join(mounts, " "), image, promtailConfigPath)
d.log("info", "启动采集器容器 "+name, "")
if _, stderr, err := client.Execute(ctx, runCmd); err != nil {
return fmt.Errorf("启动采集器容器失败: %v: %s", err, truncateForMessage(strings.TrimSpace(stderr), 400))
}
// docker run 返回 0 也可能是「起来就崩」——必须查容器状态与重启次数
if err := waitContainerRunning(ctx, client, name, 30*time.Second); err != nil {
logs, _, _ := client.Execute(ctx, fmt.Sprintf("docker logs --tail 30 %s 2>&1", name))
return fmt.Errorf("采集器容器未正常运行:%v\n最近日志:\n%s", err, truncateForMessage(strings.TrimSpace(logs), 900))
}
d.collector.RunMode = HostRunModeDocker
d.collector.Config = conf
d.log("success", fmt.Sprintf("Promtail 已以容器方式运行(%s,--restart=always)", name), "")
return nil
}
// installHostSystemd 无 Docker 环境下的兜底路径:二进制 + systemd 单元。
func (d *CollectorDeployer) installHostSystemd(ctx context.Context, client *SSHClient, conf string, caps *HostCapabilities) error {
arch, err := normalizeArch(caps.Arch)
if err != nil {
return err
}
// 确定 Promtail 版本(优先使用关联的 Loki 版本)
version := "3.0.0"
if d.loki != nil && d.loki.Version != "" {
version = d.loki.Version
}
d.log("info", fmt.Sprintf("下载 Promtail %s(%s)", version, arch), "")
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v%s/promtail-linux-%s.zip", version, arch)
dl := fmt.Sprintf("cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
if _, _, err := client.Execute(ctx, dl); err != nil {
return fmt.Errorf("下载 Promtail 失败: %w", err)
}
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
// 语法校验通过才替换正式配置
d.log("info", "校验 Promtail 配置", "")
if out, _, err := client.Execute(ctx, "/usr/local/bin/promtail -check-syntax -config.file="+promtailConfigPath+".new 2>&1"); err != nil {
msg := strings.TrimSpace(out)
// 老版本 promtail 可能没有 -check-syntax:只有明确报配置错误才阻断
if strings.Contains(msg, "flag provided but not defined") {
d.log("info", "当前 promtail 不支持 -check-syntax,跳过语法校验", "")
} else {
return fmt.Errorf("Promtail 配置校验未通过(现有配置未改动):%s", truncateForMessage(msg, 600))
}
}
if _, _, err := client.Execute(ctx, "mv -f /etc/promtail/promtail.yaml.new /etc/promtail/promtail.yaml"); err != nil {
return fmt.Errorf("替换 Promtail 配置失败: %w", err)
if err := promoteHostConfig(ctx, client); err != nil {
return err
}
// systemd 服务(生产级)
@@ -177,7 +304,7 @@ After=network-online.target
Type=simple
User=root
Group=root
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml
ExecStart=/usr/local/bin/promtail -config.file=` + promtailConfigPath + `
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
@@ -189,18 +316,18 @@ StandardError=append:/var/log/promtail.log
WantedBy=multi-user.target
`
d.log("info", "安装 systemd 服务", "")
if _, _, err := client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service)); err != nil {
if err := uploadDockerFile(ctx, client, "/etc/systemd/system/"+promtailServiceName+".service", service, "644"); err != nil {
return fmt.Errorf("创建 systemd 服务失败: %w", err)
}
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable "+promtailServiceName+" && systemctl restart "+promtailServiceName); err != nil {
return fmt.Errorf("启动 Promtail 失败: %w", err)
}
// 必须校验服务真的起来了:
// 原来只判断 systemctl 命令有没有执行错误,结果 Promtail 因配置/参数错误
// 崩溃重启 40+ 次,平台仍然显示"采集器已部署",用户以为采集是好的。
if err := waitServiceActive(ctx, client, "promtail", 20*time.Second); err != nil {
out, _, _ := client.Execute(ctx, "journalctl -u promtail -n 15 --no-pager 2>/dev/null | tail -15")
// 崩溃重启 40+ 次,平台仍然显示「采集器已部署」,用户以为采集是好的。
if err := waitServiceActive(ctx, client, promtailServiceName, 20*time.Second); err != nil {
out, _, _ := client.Execute(ctx, "journalctl -u "+promtailServiceName+" -n 15 --no-pager 2>/dev/null | tail -15")
tail := strings.TrimSpace(out)
if tail == "" {
out, _, _ = client.Execute(ctx, "tail -10 /var/log/promtail.log 2>/dev/null")
@@ -209,9 +336,13 @@ WantedBy=multi-user.target
return fmt.Errorf("Promtail 启动后未处于运行状态:%v\n最近日志:\n%s", err, truncateForMessage(tail, 900))
}
// 反向清理:同名容器若存在(从 docker 模式切回来),停掉避免重复采集
client.Execute(ctx, "docker rm -f "+promtailContainerName(d.collector.ID)+" >/dev/null 2>&1 || true")
// 保存配置快照
d.collector.RunMode = HostRunModeSystemd
d.collector.Config = conf
d.log("success", "Promtail 部署完成", "")
d.log("success", "Promtail 已以 systemd 服务方式运行", "")
return nil
}
@@ -291,10 +422,19 @@ func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
return err
}
defer client.Close()
client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true")
client.Execute(ctx, "systemctl disable promtail 2>/dev/null || true")
client.Execute(ctx, "rm -rf /etc/systemd/system/promtail.service /usr/local/bin/promtail /etc/promtail /opt/promtail /var/lib/promtail")
client.Execute(ctx, "systemctl daemon-reload")
name := promtailContainerName(d.collector.ID)
// 两种托管方式都清一遍:RunMode 对老记录是空的,且用户可能手工装过另一种。
// 顺序固定:先停容器/服务,再删文件 —— 否则进程还在跑就没了配置。
if out, _, err := client.Execute(ctx, "docker rm -f "+name+" 2>&1"); err == nil {
d.log("info", "已删除采集器容器 "+strings.TrimSpace(out), "")
}
client.Execute(ctx, "systemctl stop "+promtailServiceName+" 2>/dev/null; systemctl disable "+promtailServiceName+" 2>/dev/null")
client.Execute(ctx, "rm -f /etc/systemd/system/"+promtailServiceName+".service")
client.Execute(ctx, "systemctl daemon-reload 2>/dev/null || true")
client.Execute(ctx, "rm -rf "+promtailConfigDir+" /opt/promtail "+promtailPositionsDir+" /usr/local/bin/promtail")
d.log("info", "已清理采集器配置、positions 与二进制", "")
d.collector.RunMode = ""
return nil
}