飞书通知开发

axing
2026-02-05 / 0 评论 / 3 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2026年02月06日,已超过9天没有更新,若内容或图片失效,请留言反馈。

一、代码

#初始化目录
cd ~
mkdir feishu-forwarder
cd feishu-forwarder
go mod init feishu-forwarder
#Dockerfile
FROM golang:1.24.2-alpine AS build
WORKDIR /src

COPY go.mod ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/feishu-forwarder .

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/feishu-forwarder /feishu-forwarder
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/feishu-forwarder"]
#main.go
package main

import (
    "bytes"
    "context"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/hex"
    "encoding/json"
    "io"
    "log"
    "math/rand"
    "net/http"
    "os"
    "sort"
    "strings"
    "sync"
    "time"
)

type Alert struct {
    Status       string            `json:"status"`
    Labels       map[string]string `json:"labels"`
    Annotations  map[string]string `json:"annotations"`
    StartsAt     string            `json:"startsAt"`
    EndsAt       string            `json:"endsAt"`
    GeneratorURL string            `json:"generatorURL"`
    Fingerprint  string            `json:"fingerprint"` // Alertmanager webhook 通常会带;没有也没关系
}

type AMPayload struct {
    Status       string            `json:"status"`
    Receiver     string            `json:"receiver"`
    ExternalURL  string            `json:"externalURL"`
    GroupKey     string            `json:"groupKey"`
    CommonLabels map[string]string `json:"commonLabels"`
    Alerts       []Alert           `json:"alerts"`
}

// webhook body:text 用 content;card 用 card
type FeishuBody struct {
    Timestamp string                 `json:"timestamp,omitempty"`
    Sign      string                 `json:"sign,omitempty"`
    MsgType   string                 `json:"msg_type"`
    Content   map[string]interface{} `json:"content,omitempty"`
    Card      map[string]interface{} `json:"card,omitempty"`
}

type Target struct {
    Webhook string
    Secret  string
    Name    string // 用于日志
}

type Config struct {
    // 路由:按 severity 选择目标群
    WebhooksCritical string
    WebhooksWarning  string
    WebhooksDefault  string

    SecretCritical string
    SecretWarning  string
    SecretDefault  string

    // 消息类型:card / text
    MsgType string

    // 去重窗口
    DedupTTL time.Duration

    // 重试
    RetryMax  int
    RetryBase time.Duration

    // 限流(全局)
    RateQPS   float64
    RateBurst int

    // HTTP
    SendTimeout time.Duration
}

func loadConfig() Config {
    cfg := Config{
        WebhooksCritical: strings.TrimSpace(os.Getenv("FEISHU_WEBHOOKS_CRITICAL")),
        WebhooksWarning:  strings.TrimSpace(os.Getenv("FEISHU_WEBHOOKS_WARNING")),
        WebhooksDefault:  strings.TrimSpace(os.Getenv("FEISHU_WEBHOOKS_DEFAULT")),

        SecretCritical: strings.TrimSpace(os.Getenv("FEISHU_SECRET_CRITICAL")),
        SecretWarning:  strings.TrimSpace(os.Getenv("FEISHU_SECRET_WARNING")),
        SecretDefault:  strings.TrimSpace(os.Getenv("FEISHU_SECRET_DEFAULT")),

        MsgType: strings.ToLower(strings.TrimSpace(os.Getenv("FEISHU_MSG_TYPE"))),

        DedupTTL:    mustParseDuration(getEnvDefault("DEDUP_TTL", "10m")),
        RetryMax:    mustParseInt(getEnvDefault("RETRY_MAX", "3")),
        RetryBase:   mustParseDuration(getEnvDefault("RETRY_BASE", "300ms")),
        RateQPS:     mustParseFloat(getEnvDefault("RATE_QPS", "2")), // 每秒2条
        RateBurst:   mustParseInt(getEnvDefault("RATE_BURST", "5")), // 突发5条
        SendTimeout: mustParseDuration(getEnvDefault("SEND_TIMEOUT", "6s")),
    }

    if cfg.MsgType != "text" && cfg.MsgType != "card" {
        cfg.MsgType = "card"
    }

    // 兼容:如果你只设置了旧的 FEISHU_WEBHOOKS/FEISHU_SECRET
    if cfg.WebhooksDefault == "" {
        cfg.WebhooksDefault = strings.TrimSpace(os.Getenv("FEISHU_WEBHOOKS"))
    }
    if cfg.SecretDefault == "" {
        cfg.SecretDefault = strings.TrimSpace(os.Getenv("FEISHU_SECRET"))
    }

    return cfg
}

/* ============ 去重(内存 TTL) ============ */
type Deduper struct {
    mu   sync.Mutex
    ttl  time.Duration
    data map[string]time.Time
}

func NewDeduper(ttl time.Duration) *Deduper {
    d := &Deduper{
        ttl:  ttl,
        data: make(map[string]time.Time),
    }
    go d.gcLoop()
    return d
}

func (d *Deduper) Allow(key string) bool {
    if d.ttl <= 0 {
        return true
    }
    now := time.Now()

    d.mu.Lock()
    defer d.mu.Unlock()

    if t, ok := d.data[key]; ok {
        if now.Sub(t) < d.ttl {
            return false
        }
    }
    d.data[key] = now
    return true
}

func (d *Deduper) gcLoop() {
    t := time.NewTicker(1 * time.Minute)
    defer t.Stop()
    for range t.C {
        now := time.Now()
        d.mu.Lock()
        for k, v := range d.data {
            if now.Sub(v) > d.ttl*2 {
                delete(d.data, k)
            }
        }
        d.mu.Unlock()
    }
}

/* ============ 简单全局限流(token bucket) ============ */
type RateLimiter struct {
    ch   chan struct{}
    stop chan struct{}
}

func NewRateLimiter(qps float64, burst int) *RateLimiter {
    if qps <= 0 {
        return nil
    }
    if burst < 1 {
        burst = 1
    }
    rl := &RateLimiter{
        ch:   make(chan struct{}, burst),
        stop: make(chan struct{}),
    }
    // 初始塞满 burst
    for i := 0; i < burst; i++ {
        rl.ch <- struct{}{}
    }

    interval := time.Duration(float64(time.Second) / qps)
    if interval < 10*time.Millisecond {
        interval = 10 * time.Millisecond
    }

    go func() {
        t := time.NewTicker(interval)
        defer t.Stop()
        for {
            select {
            case <-t.C:
                select {
                case rl.ch <- struct{}{}:
                default:
                }
            case <-rl.stop:
                return
            }
        }
    }()

    return rl
}

func (rl *RateLimiter) Acquire(ctx context.Context) error {
    if rl == nil {
        return nil
    }
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-rl.ch:
        return nil
    }
}

/* ============ 飞书签名 ============ */
func genFeishuSign(secret, timestamp string) string {
    stringToSign := timestamp + "\n" + secret
    mac := hmac.New(sha256.New, []byte(stringToSign))
    sum := mac.Sum(nil)
    return base64.StdEncoding.EncodeToString(sum)
}

/* ============ severity 路由 ============ */
func getSeverity(p AMPayload) string {
    s := strings.ToLower(strings.TrimSpace(p.CommonLabels["severity"]))
    if s == "" && len(p.Alerts) > 0 {
        s = strings.ToLower(strings.TrimSpace(p.Alerts[0].Labels["severity"]))
    }
    switch s {
    case "critical", "fatal", "sev0", "sev1":
        return "critical"
    case "warning", "warn", "sev2":
        return "warning"
    default:
        return "default"
    }
}

func splitWebhooks(s string) []string {
    s = strings.TrimSpace(s)
    if s == "" {
        return nil
    }
    parts := strings.Split(s, ",")
    var out []string
    for _, x := range parts {
        x = strings.TrimSpace(x)
        if x != "" {
            out = append(out, x)
        }
    }
    return out
}

func selectTargets(cfg Config, sev string) []Target {
    var whs []string
    var secret string
    var name string

    switch sev {
    case "critical":
        whs = splitWebhooks(cfg.WebhooksCritical)
        secret = cfg.SecretCritical
        name = "critical"
    case "warning":
        whs = splitWebhooks(cfg.WebhooksWarning)
        secret = cfg.SecretWarning
        name = "warning"
    default:
        whs = splitWebhooks(cfg.WebhooksDefault)
        secret = cfg.SecretDefault
        name = "default"
    }

    // fallback:如果 critical/warning 没配,则退回 default
    if len(whs) == 0 {
        whs = splitWebhooks(cfg.WebhooksDefault)
        secret = cfg.SecretDefault
        name = "default(fallback)"
    }

    var out []Target
    for _, w := range whs {
        out = append(out, Target{Webhook: w, Secret: secret, Name: name})
    }
    return out
}

/* ============ 去重 key ============ */
func alertKey(a Alert) string {
    if a.Fingerprint != "" {
        return a.Fingerprint
    }
    // 没 fingerprint 就用 labels 自己算一个稳定 hash
    keys := make([]string, 0, len(a.Labels))
    for k := range a.Labels {
        keys = append(keys, k)
    }
    sort.Strings(keys)

    var b strings.Builder
    for _, k := range keys {
        b.WriteString(k)
        b.WriteString("=")
        b.WriteString(a.Labels[k])
        b.WriteString(";")
    }
    sum := sha256.Sum256([]byte(b.String()))
    return hex.EncodeToString(sum[:])
}

/* ============ 消息构建 ============ */
func formatText(p AMPayload, alerts []Alert) string {
    var b strings.Builder
    b.WriteString("【Alertmanager】" + strings.ToUpper(p.Status) + "\n")
    if v := p.CommonLabels["alertname"]; v != "" {
        b.WriteString("alertname: " + v + "\n")
    }
    if v := p.CommonLabels["severity"]; v != "" {
        b.WriteString("severity: " + v + "\n")
    }
    b.WriteString("alerts: ")
    b.WriteString(intToString(len(alerts)))
    b.WriteString("\n\n")

    for i, a := range alerts {
        b.WriteString("#")
        b.WriteString(intToString(i + 1))
        b.WriteString(" ")
        if an := a.Labels["alertname"]; an != "" {
            b.WriteString(an)
        }
        if inst := a.Labels["instance"]; inst != "" {
            b.WriteString(" @ " + inst)
        }
        b.WriteString("\n")
        if s := a.Annotations["summary"]; s != "" {
            b.WriteString("summary: " + s + "\n")
        }
        if d := a.Annotations["description"]; d != "" {
            b.WriteString("desc: " + d + "\n")
        }
        if a.GeneratorURL != "" {
            b.WriteString("url: " + a.GeneratorURL + "\n")
        }
        b.WriteString("\n")
    }
    return b.String()
}

func buildCard(p AMPayload, sev string, alerts []Alert) map[string]interface{} {
    alertname := p.CommonLabels["alertname"]
    if alertname == "" && len(alerts) > 0 {
        alertname = alerts[0].Labels["alertname"]
    }

    // 颜色:FIRING + critical 用 red;warning 用 orange;resolved 用 green
    template := "blue"
    if strings.ToLower(p.Status) == "firing" {
        if sev == "critical" {
            template = "red"
        } else if sev == "warning" {
            template = "orange"
        } else {
            template = "blue"
        }
    } else {
        template = "green"
    }

    title := "[" + strings.ToUpper(p.Status) + "][" + sev + "] " + alertname + " (" + intToString(len(alerts)) + ")"

    // 内容用 markdown,最多展示前 5 条(避免卡片过长)
    maxShow := 5
    show := alerts
    more := 0
    if len(alerts) > maxShow {
        show = alerts[:maxShow]
        more = len(alerts) - maxShow
    }

    var md strings.Builder
    md.WriteString("**Receiver:** " + p.Receiver + "\n")
    if p.ExternalURL != "" {
        md.WriteString("**Alertmanager:** " + p.ExternalURL + "\n")
    }
    md.WriteString("\n")

    for i, a := range show {
        an := a.Labels["alertname"]
        inst := a.Labels["instance"]
        md.WriteString("**#" + intToString(i+1) + "** " + an)
        if inst != "" {
            md.WriteString(" @ `" + inst + "`")
        }
        md.WriteString("\n")
        if s := a.Annotations["summary"]; s != "" {
            md.WriteString("- **summary:** " + s + "\n")
        }
        if d := a.Annotations["description"]; d != "" {
            md.WriteString("- **desc:** " + d + "\n")
        }
        if a.StartsAt != "" {
            md.WriteString("- **startsAt:** " + a.StartsAt + "\n")
        }
        if a.GeneratorURL != "" {
            md.WriteString("- **url:** " + a.GeneratorURL + "\n")
        }
        md.WriteString("\n")
    }
    if more > 0 {
        md.WriteString("…还有 **" + intToString(more) + "** 条未展开\n")
    }

    // 如果有 generatorURL,就加一个按钮(取第一条有 url 的)
    btnURL := ""
    for _, a := range alerts {
        if a.GeneratorURL != "" {
            btnURL = a.GeneratorURL
            break
        }
    }

    elements := []interface{}{
        map[string]interface{}{"tag": "markdown", "content": md.String()},
    }

    if btnURL != "" {
        elements = append(elements,
            map[string]interface{}{"tag": "hr"},
            map[string]interface{}{
                "tag": "action",
                "actions": []interface{}{
                    map[string]interface{}{
                        "tag":  "button",
                        "text": map[string]interface{}{"tag": "plain_text", "content": "打开规则/图表"},
                        "type": "primary",
                        "url":  btnURL,
                    },
                },
            },
        )
    }

    card := map[string]interface{}{
        "config": map[string]interface{}{
            "wide_screen_mode": true,
        },
        "header": map[string]interface{}{
            "title":    map[string]interface{}{"tag": "plain_text", "content": title},
            "template": template,
        },
        "elements": elements,
    }
    return card
}

/* ============ 发送:重试 + 限流 ============ */
type httpError struct {
    code int
    body string
}

func (e *httpError) Error() string {
    if e.body != "" {
        return "http status " + intToString(e.code) + " body=" + e.body
    }
    return "http status " + intToString(e.code)
}

func isRetryableStatus(code int) bool {
    return code == 429 || code >= 500
}

func doPost(ctx context.Context, client *http.Client, url string, payload []byte) error {
    req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
    req.Header.Set("Content-Type", "application/json")
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode/100 == 2 {
        return nil
    }

    // 读一点返回体,方便排查(最多 2KB)
    b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
    return &httpError{code: resp.StatusCode, body: strings.TrimSpace(string(b))}
}

func sendWithRetry(ctx context.Context, rl *RateLimiter, cfg Config, t Target, body FeishuBody) error {
    client := &http.Client{Timeout: cfg.SendTimeout}

    payload, _ := json.Marshal(body)

    r := rand.New(rand.NewSource(time.Now().UnixNano()))
    var lastErr error

    for i := 0; i < cfg.RetryMax; i++ {
        // 限流:每次尝试都要拿 token(避免重试时把飞书打爆)
        if err := rl.Acquire(ctx); err != nil {
            return err
        }

        err := doPost(ctx, client, t.Webhook, payload)
        if err == nil {
            return nil
        }
        lastErr = err

        // 只有可重试错误才重试
        retry := false
        if he, ok := err.(*httpError); ok {
            retry = isRetryableStatus(he.code)
        } else {
            retry = true // 网络错误等
        }
        if !retry || i == cfg.RetryMax-1 {
            break
        }

        // 退避:base * 2^i + jitter(0~100ms)
        sleep := cfg.RetryBase * time.Duration(1<<i)
        sleep += time.Duration(r.Intn(100)) * time.Millisecond

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(sleep):
        }
    }

    return lastErr
}

func buildFeishuBody(cfg Config, sev string, p AMPayload, alerts []Alert, webhookSecret string) FeishuBody {
    ts := ""
    sign := ""
    if webhookSecret != "" {
        ts = int64ToString(time.Now().Unix())
        sign = genFeishuSign(webhookSecret, ts)
    }

    if cfg.MsgType == "text" {
        return FeishuBody{
            Timestamp: ts,
            Sign:      sign,
            MsgType:   "text",
            Content: map[string]interface{}{
                "text": formatText(p, alerts),
            },
        }
    }

    // card
    return FeishuBody{
        Timestamp: ts,
        Sign:      sign,
        MsgType:   "interactive",
        Card:      buildCard(p, sev, alerts),
    }
}

/* ============ helpers ============ */
func getEnvDefault(k, def string) string {
    v := strings.TrimSpace(os.Getenv(k))
    if v == "" {
        return def
    }
    return v
}
func mustParseDuration(s string) time.Duration {
    d, err := time.ParseDuration(s)
    if err != nil {
        return 0
    }
    return d
}
func mustParseInt(s string) int {
    n := 0
    for _, ch := range s {
        if ch >= '0' && ch <= '9' {
            n = n*10 + int(ch-'0')
        }
    }
    if n == 0 {
        return 0
    }
    return n
}
func mustParseFloat(s string) float64 {
    // 简易 parse:支持 "2" "2.5"
    var n, frac, div float64
    div = 1
    seenDot := false
    for _, ch := range s {
        if ch == '.' {
            seenDot = true
            continue
        }
        if ch < '0' || ch > '9' {
            continue
        }
        if !seenDot {
            n = n*10 + float64(ch-'0')
        } else {
            frac = frac*10 + float64(ch-'0')
            div *= 10
        }
    }
    return n + frac/div
}

func intToString(x int) string { return int64ToString(int64(x)) }
func int64ToString(x int64) string {
    if x == 0 {
        return "0"
    }
    neg := x < 0
    if neg {
        x = -x
    }
    var buf [32]byte
    i := len(buf)
    for x > 0 {
        i--
        buf[i] = byte('0' + x%10)
        x /= 10
    }
    if neg {
        i--
        buf[i] = '-'
    }
    return string(buf[i:])
}

/* ============ main ============ */
func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)

    cfg := loadConfig()
    deduper := NewDeduper(cfg.DedupTTL)
    rl := NewRateLimiter(cfg.RateQPS, cfg.RateBurst)

    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(200)
        w.Write([]byte("ok"))
    })

    http.HandleFunc("/alertmanager", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != "POST" {
            w.WriteHeader(405)
            return
        }

        var p AMPayload
        if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
            log.Printf("decode failed: remote=%s err=%v", r.RemoteAddr, err)
            w.WriteHeader(400)
            w.Write([]byte(err.Error()))
            return
        }

        sev := getSeverity(p)
        log.Printf("recv webhook: remote=%s status=%s receiver=%s alerts=%d severity=%s alertname=%s",
            r.RemoteAddr, p.Status, p.Receiver, len(p.Alerts), sev, p.CommonLabels["alertname"])

        // 去重(只对 firing 做,resolved 不去重,避免“恢复消息被吞”)
        alertsToSend := make([]Alert, 0, len(p.Alerts))
        suppressed := 0
        for _, a := range p.Alerts {
            if strings.ToLower(p.Status) == "firing" && cfg.DedupTTL > 0 {
                key := "firing:" + alertKey(a)
                if !deduper.Allow(key) {
                    suppressed++
                    continue
                }
            }
            alertsToSend = append(alertsToSend, a)
        }

        if len(alertsToSend) == 0 {
            log.Printf("dedup suppressed all alerts: suppressed=%d total=%d", suppressed, len(p.Alerts))
            w.WriteHeader(200)
            w.Write([]byte("ok"))
            return
        }

        targets := selectTargets(cfg, sev)
        if len(targets) == 0 {
            log.Printf("no targets configured for severity=%s", sev)
            w.WriteHeader(500)
            w.Write([]byte("no targets configured"))
            return
        }

        okCount := 0
        failCount := 0

        // 给本次请求一个总体 deadline(避免 handler 卡太久)
        ctx, cancel := context.WithTimeout(r.Context(), cfg.SendTimeout*time.Duration(cfg.RetryMax+1))
        defer cancel()

        for _, t := range targets {
            body := buildFeishuBody(cfg, sev, p, alertsToSend, t.Secret)
            err := sendWithRetry(ctx, rl, cfg, t, body)
            if err != nil {
                log.Printf("send failed: group=%s target=%s err=%v", t.Name, t.Webhook, err)
                failCount++
                continue
            }
            log.Printf("send ok: group=%s target=%s", t.Name, t.Webhook)
            okCount++
        }

        log.Printf("send summary: ok=%d fail=%d targets=%d suppressed=%d severity=%s",
            okCount, failCount, len(targets), suppressed, sev)

        if okCount == 0 {
            w.WriteHeader(502)
            w.Write([]byte("failed"))
            return
        }
        w.WriteHeader(200)
        w.Write([]byte("ok"))
    })

    addr := ":8080"
    log.Printf("listening on %s msgType=%s dedup=%s retryMax=%d rateQps=%.2f burst=%d",
        addr, cfg.MsgType, cfg.DedupTTL, cfg.RetryMax, cfg.RateQPS, cfg.RateBurst)
    log.Fatal(http.ListenAndServe(addr, nil))
}

二、构建部署

docker build -t harbor.axzys.cn/monitoring/feishu-forwarder:0.1.0 .
docker push  harbor.axzys.cn/monitoring/feishu-forwarder:0.1.0 .
cat feishu.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: feishu-forwarder
  namespace: monitoring
spec:
  replicas: 1
  selector:
    matchLabels: { app: feishu-forwarder }
  template:
    metadata:
      labels: { app: feishu-forwarder }
    spec:
      containers:
      - name: app
        image: harbor.axzys.cn/monitoring/feishu-forwarder:0.1.2
        imagePullPolicy: Always
        ports:
        - containerPort: 8080
        env:
        - name: FEISHU_WEBHOOKS_CRITICAL
          valueFrom: { secretKeyRef: { name: feishu-forwarder-secret, key: FEISHU_WEBHOOKS_CRITICAL } }
        - name: FEISHU_WEBHOOKS_WARNING
          valueFrom: { secretKeyRef: { name: feishu-forwarder-secret, key: FEISHU_WEBHOOKS_WARNING } }
        - name: FEISHU_WEBHOOKS_DEFAULT
          valueFrom: { secretKeyRef: { name: feishu-forwarder-secret, key: FEISHU_WEBHOOKS_DEFAULT } }

        - name: FEISHU_SECRET_CRITICAL
          valueFrom: { secretKeyRef: { name: feishu-forwarder-secret, key: FEISHU_SECRET_CRITICAL } }
        - name: FEISHU_SECRET_WARNING
          valueFrom: { secretKeyRef: { name: feishu-forwarder-secret, key: FEISHU_SECRET_WARNING } }
        - name: FEISHU_SECRET_DEFAULT
          valueFrom: { secretKeyRef: { name: feishu-forwarder-secret, key: FEISHU_SECRET_DEFAULT } }
        - name: FEISHU_MSG_TYPE
          value: "card"          # card / text
        - name: DEDUP_TTL
          value: "10m"
        - name: RETRY_MAX
          value: "3"
        - name: RETRY_BASE
          value: "300ms"
        - name: RATE_QPS
          value: "2"
        - name: RATE_BURST
          value: "5"
        - name: SEND_TIMEOUT
          value: "6s"
        readinessProbe:
          httpGet: { path: /healthz, port: 8080 }
          initialDelaySeconds: 2
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: feishu-forwarder
  namespace: monitoring
spec:
  selector: { app: feishu-forwarder }
  ports:
  - name: http
    port: 8080
    targetPort: 8080
  type: ClusterIP
cat feishu-secret.yaml 
apiVersion: v1
kind: Secret
metadata:
  name: feishu-forwarder-secret
  namespace: monitoring
type: Opaque
stringData:
  FEISHU_WEBHOOKS_CRITICAL: "https://open.feishu.cn/open-apis/bot/v2/hook/飞书token"
  FEISHU_WEBHOOKS_WARNING:  "https://open.feishu.cn/open-apis/bot/v2/hook/飞书token"
  FEISHU_WEBHOOKS_DEFAULT:  "https://open.feishu.cn/open-apis/bot/v2/hook/xxxx"

  # 如果你开了“签名校验”,不同群机器人 secret 往往不同
  FEISHU_SECRET_CRITICAL: "SEC_xxx"
  FEISHU_SECRET_WARNING:  "SEC_yyy"
  FEISHU_SECRET_DEFAULT:  "SEC_zzz"

 cat alertmanagerConfig.yaml 
apiVersion: monitoring.coreos.com/v1alpha1
kind: AlertmanagerConfig
metadata:
  name: feishu-forwarder
  namespace: monitoring
  labels:
    alertmanagerConfig: main   # 这行是否需要,取决于你的 Alertmanager 选择器
spec:
  route:
    receiver: feishu-forwarder
    groupWait: 30s
    groupInterval: 5m
    repeatInterval: 30m
  receivers:
  - name: feishu-forwarder
    webhookConfigs:
    - url: http://feishu-forwarder.monitoring.svc:8080/alertmanager
      sendResolved: true
root@k8s-01:/woke/prometheus/feishu# kubectl get pod -n monitoring 
NAME                                   READY   STATUS    RESTARTS       AGE
alertmanager-main-0                    2/2     Running   0              7d2h
alertmanager-main-1                    2/2     Running   0              7d2h
alertmanager-main-2                    2/2     Running   0              7d2h
blackbox-exporter-7fcbd888d-zv6z6      3/3     Running   0              15d
feishu-forwarder-8559bf6b68-njzvq      1/1     Running   0              68m
grafana-7ff454c477-l9x2k               1/1     Running   0              15d
kube-state-metrics-78f95f79bb-wpcln    3/3     Running   0              15d
node-exporter-2vq26                    2/2     Running   2 (35d ago)    39d
node-exporter-622pm                    2/2     Running   24 (35d ago)   39d
node-exporter-rl67z                    2/2     Running   22 (35d ago)   39d
prometheus-adapter-585d9c5dd5-bfsxw    1/1     Running   0              8d
prometheus-adapter-585d9c5dd5-pcrnd    1/1     Running   0              8d
prometheus-k8s-0                       2/2     Running   0              7d2h
prometheus-k8s-1                       2/2     Running   0              7d2h
prometheus-operator-78967669c9-5pk25   2/2     Running   0              7d2h

三、调试
ml9m8bxs.png
ml9mbehs.png

 kubectl -n monitoring port-forward svc/alertmanager-main 19093:9093
 kubectl -n monitoring logs -f deploy/feishu-forwarder
 curl -i -X POST http://127.0.0.1:19093/api/v2/alerts \
  -H 'Content-Type: application/json' \
  -d '[{
    "labels":{"alertname":"axingWarning","severity":"warning","instance":"demo:9100","namespace":"monitoring"},
    "annotations":{"summary":"injected warning","description":"from alertmanager api"},
    "startsAt":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"
  }]'

mlammitk.png

操作过程中出现了 prometheus 已经告警 并且已经到Alertmanager了 但是 feishu-forwarder 没有收到 解决方法 是加上  不然他只会通知monitoring这个名称空间的告警信息
    matchers:
    - name: prometheus
      value: monitoring/k8s
还有就是InfoInhibitor 一直刷屏通知告警 有两种解决方法 一、就是改上面的feishu-secret把FEISHU_WEBHOOKS_DEFAULT注释掉 还有一种就是下面这种只通知warning/critical
root@k8s-01:/woke/prometheus/feishu# cat alertmanagerConfig.yaml 
apiVersion: monitoring.coreos.com/v1alpha1
kind: AlertmanagerConfig
metadata:
  name: feishu-forwarder
  namespace: monitoring
  labels:
    alertmanagerConfig: main
spec:
  route:
    receiver: feishu-forwarder
    groupWait: 30s
    groupInterval: 5m
    repeatInterval: 30m
    matchers:
    - name: prometheus
      value: monitoring/k8s
      matchType: "="
    # 建议:只推 warning/critical,InfoInhibitor(severity=none) 就不会再刷飞书
    - name: severity
      value: warning|critical
      matchType: "=~"
  receivers:
  - name: feishu-forwarder
    webhookConfigs:
    - url: http://feishu-forwarder.monitoring.svc:8080/alertmanager
      sendResolved: true
0

评论 (0)

取消