Files
LokiStack/backend/services/collector_deployer.go
T
Logan.LiandClaude 673b2c60c8 feat: 新增「部署客户端」日志采集器模块
三种采集器(host-promtail / k8s-daemonset / k8s-fluentbit-sidecar),每种关联已部署 Loki:
- host-promtail:SSH 安装 Promtail(复用 deployBinary)
- k8s-daemonset:helm 安装 Promtail DaemonSet(复用 deployHelm)
- k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar

含 Collector 数据模型、三个部署器、配置模板、CRUD API、前端页面。
附带 UI 优化:ErrorBoundary、部署卡片浏览器访问按钮、菜单名称调整。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-29 19:26:41 +08:00

332 lines
10 KiB
Go
Raw 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 (
"context"
"fmt"
"os"
"os/exec"
"strings"
"lokideploy/models"
)
// CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程
type CollectorDeployer struct {
collector *models.Collector
loki *models.LokiDeployment
logFunc func(level, message, detail string)
}
func NewCollectorDeployer(c *models.Collector, l *models.LokiDeployment, logFunc func(level, message, detail string)) *CollectorDeployer {
return &CollectorDeployer{collector: c, loki: l, logFunc: logFunc}
}
func (d *CollectorDeployer) log(level, message, detail string) {
if d.logFunc != nil {
d.logFunc(level, message, detail)
}
}
// kubeconfigEnv 构造 kubectl/helm 所需的 KUBECONFIG 环境变量,返回 cleanup 删除临时文件
func kubeconfigEnv(cluster *models.Cluster) (env []string, cleanup func(), err error) {
kubeconfig, err := resolveKubeconfig(cluster)
if err != nil {
return nil, nil, err
}
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
if err != nil {
return nil, nil, err
}
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
cleanup = func() { os.Remove(kubeFile.Name()) }
return env, cleanup, nil
}
// Install 按采集器类型分发安装
func (d *CollectorDeployer) Install(ctx context.Context) error {
endpoint := d.loki.Endpoint
if endpoint == "" {
return fmt.Errorf("关联的 Loki 尚未部署,缺少推送地址(endpoint),请先成功部署该 Loki")
}
d.log("info", fmt.Sprintf("开始部署采集器 %s(类型 %s)", d.collector.Name, d.collector.Type), "")
var err error
switch d.collector.Type {
case "host-promtail":
err = d.installHost(ctx, endpoint)
case "k8s-daemonset":
err = d.installDaemonset(ctx, endpoint)
case "k8s-fluentbit-sidecar":
err = d.installSidecar(ctx, endpoint)
default:
return fmt.Errorf("不支持的采集器类型: %s", d.collector.Type)
}
if err != nil {
d.log("error", fmt.Sprintf("采集器部署失败: %v", err), "")
return err
}
d.log("success", "采集器部署完成", "")
return nil
}
// Uninstall 按采集器类型分发卸载
func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
switch d.collector.Type {
case "host-promtail":
return d.uninstallHost(ctx)
case "k8s-daemonset":
return d.uninstallDaemonset(ctx)
case "k8s-fluentbit-sidecar":
return d.uninstallSidecar(ctx)
}
return nil
}
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
host := d.collector.Host
if host == nil {
return fmt.Errorf("主机采集器缺少目标主机")
}
password, err := DecryptCred(host.Password)
if err != nil {
return err
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
return err
}
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)
}
// 下载 Promtail
d.log("info", fmt.Sprintf("下载 Promtail(%s)", arch), "")
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v3.0.0/promtail-linux-%s.zip", arch)
dl := fmt.Sprintf("mkdir -p /opt/promtail /etc/promtail && 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 失败: %v", err)
}
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
// 配置
logPath := d.collector.LogPath
if logPath == "" {
logPath = "/var/log/*.log"
}
conf := PromtailConfig(endpoint, logPath)
client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf))
// systemd 服务
service := `[Unit]
Description=Promtail Log Collector
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
`
client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service))
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
return fmt.Errorf("启动 Promtail 失败: %v", err)
}
return nil
}
func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
host := d.collector.Host
if host == nil {
return nil
}
password, _ := DecryptCred(host.Password)
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
return err
}
defer client.Close()
client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true; systemctl disable promtail 2>/dev/null || true; rm -f /etc/systemd/system/promtail.service /usr/local/bin/promtail; systemctl daemon-reload")
return nil
}
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
return fmt.Errorf("DaemonSet 采集器缺少目标集群")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "logging"
}
values := PromtailHelmValues(endpoint)
valuesFile, err := os.CreateTemp("", "promtail-values-*.yaml")
if err != nil {
return err
}
defer os.Remove(valuesFile.Name())
valuesFile.WriteString(values)
valuesFile.Close()
d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "")
// 先添加 grafana repo(幂等)
repo := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts")
repo.Env = env
repo.CombinedOutput()
exec.CommandContext(ctx, "helm", "repo", "update").CombinedOutput()
cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", "promtail", "grafana/promtail",
"-f", valuesFile.Name(), "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("helm 安装 Promtail 失败: %v\n%s", err, string(out))
}
return nil
}
func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
cluster := d.collector.Cluster
if cluster == nil {
return nil
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "logging"
}
cmd := exec.CommandContext(ctx, "helm", "uninstall", "promtail", "--namespace", ns)
cmd.Env = env
cmd.CombinedOutput()
return nil
}
// ===== k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar =====
func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
return fmt.Errorf("Sidecar 采集器缺少目标集群")
}
target := d.collector.TargetDeployment
if target == "" {
return fmt.Errorf("Sidecar 采集器缺少目标 Deployment")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "default"
}
logPath := d.collector.LogPath
if logPath == "" {
logPath = "/var/log/app/*.log"
}
app := target
// 1. 检查目标 deployment 是否存在
d.log("info", fmt.Sprintf("检查目标 Deployment %s/%s", ns, target), "")
check := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns)
check.Env = env
if out, err := check.CombinedOutput(); err != nil {
return fmt.Errorf("目标 Deployment 不存在: %s/%s\n%s", ns, target, string(out))
}
// 2. 幂等检查:是否已有 fluent-bit 容器
getNames := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns, "-o", "jsonpath={.spec.template.spec.containers[*].name}")
getNames.Env = env
namesOut, _ := getNames.CombinedOutput()
if strings.Contains(string(namesOut), "fluent-bit") {
d.log("warn", "目标 Deployment 已含 fluent-bit 容器,跳过注入", "")
return nil
}
// 3. 部署 ConfigMap
lokiHost, lokiPort := parseLokiURL(endpoint)
conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns)
cm := FluentbitConfigMapYAML(conf, ns)
cmFile, _ := os.CreateTemp("", "fluent-bit-cm-*.yaml")
defer os.Remove(cmFile.Name())
cmFile.WriteString(cm)
cmFile.Close()
d.log("info", "部署 Fluent Bit ConfigMap", "")
applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile.Name())
applyCm.Env = env
if out, err := applyCm.CombinedOutput(); err != nil {
return fmt.Errorf("ConfigMap 部署失败: %v\n%s", err, string(out))
}
// 4. patch 注入 sidecar
d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "")
patchFile, _ := os.CreateTemp("", "sidecar-patch-*.yaml")
defer os.Remove(patchFile.Name())
patchFile.WriteString(SidecarPatchYAML)
patchFile.Close()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
patch.Env = env
if out, err := patch.CombinedOutput(); err != nil {
return fmt.Errorf("Sidecar 注入失败: %v\n%s", err, string(out))
}
// 5. 等待 rollout
d.log("info", "等待 Pod 重建", "")
rollout := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/"+target, "-n", ns, "--timeout=5m")
rollout.Env = env
rollout.CombinedOutput()
return nil
}
func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error {
cluster := d.collector.Cluster
if cluster == nil || d.collector.TargetDeployment == "" {
return nil
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "default"
}
patchFile, _ := os.CreateTemp("", "sidecar-rm-*.yaml")
defer os.Remove(patchFile.Name())
patchFile.WriteString(SidecarRemovePatchYAML)
patchFile.Close()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
patch.Env = env
patch.CombinedOutput()
return nil
}