package alerts import ( "context" "encoding/json" "fmt" "log" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) const ( // Seuils d'alerte, en % de la limite (CPU/mémoire) ou de la capacité (PVC). resourceHighThresholdPct = 90 pvcFullThresholdPct = 85 // Nombre de ticks consécutifs au-dessus du seuil avant notification // (pollInterval = 30s, soit ~90s) : absorbe les pics transitoires (déploiement // en cours, backup Velero, migration...) sans notifier à tort. consecutiveTicksBeforeAlert = 3 ) // send envoie un message si un notifier est disponible, journalise en cas d'échec. func (w *Watcher) send(ctx context.Context, notifier Notifier, msg string) { if notifier == nil { return } if err := notifier.Notify(ctx, msg); err != nil { log.Printf("alerts: notification échouée: %v", err) } } // podResourcePercents calcule le % d'usage CPU/mémoire d'un pod par rapport // aux limites déclarées dans son spec. ok=false si un conteneur n'a pas de // limite définie (pourcentage non significatif dans ce cas — on ne peut pas // dire à quel point il est "plein"). func podResourcePercents(pod corev1.Pod, cpuMilli, memBytes int64) (cpuPct, memPct int, ok bool) { var cpuLimit, memLimit int64 for _, c := range pod.Spec.Containers { cl := c.Resources.Limits.Cpu() ml := c.Resources.Limits.Memory() if cl.IsZero() || ml.IsZero() { return 0, 0, false } cpuLimit += cl.MilliValue() memLimit += ml.Value() } if cpuLimit == 0 || memLimit == 0 { return 0, 0, false } return int(cpuMilli * 100 / cpuLimit), int(memBytes * 100 / memLimit), true } // checkResourceUsage alerte si un pod dépasse resourceHighThresholdPct de CPU // ou de mémoire pendant consecutiveTicksBeforeAlert ticks d'affilée. seenHigh // est alimenté pour la purge de récupération en fin de tick (voir // clearResourceRecovered). func (w *Watcher) checkResourceUsage(ctx context.Context, loadNotifier func() Notifier, ns string, pods []corev1.Pod, seenHigh map[podKey]bool) { if w.metricsClient == nil { return } list, err := w.metricsClient.MetricsV1beta1().PodMetricses(ns).List(ctx, metav1.ListOptions{}) if err != nil { return // metrics-server temporairement indisponible : pas une panne à notifier } usage := make(map[string][2]int64, len(list.Items)) // pod -> [cpuMilli, memBytes] for _, m := range list.Items { var cpu, mem int64 for _, c := range m.Containers { cpu += c.Usage.Cpu().MilliValue() mem += c.Usage.Memory().Value() } usage[m.Name] = [2]int64{cpu, mem} } for _, pod := range pods { u, ok := usage[pod.Name] if !ok { continue } cpuPct, memPct, ok := podResourcePercents(pod, u[0], u[1]) if !ok || (cpuPct < resourceHighThresholdPct && memPct < resourceHighThresholdPct) { continue } key := podKey{namespace: ns, pod: pod.Name} seenHigh[key] = true w.resourceHigh[key]++ if w.resourceHigh[key] == consecutiveTicksBeforeAlert { w.send(ctx, loadNotifier(), fmt.Sprintf( "🟠 [%s] pod %s : usage élevé — CPU %d%%, mémoire %d%% (limite)", ns, pod.Name, cpuPct, memPct)) } } } // clearResourceRecovered notifie le retour sous le seuil CPU/mémoire et purge // l'état des pods qui ne sont plus au-dessus du seuil ce tick-ci. func (w *Watcher) clearResourceRecovered(ctx context.Context, loadNotifier func() Notifier, seenHigh map[podKey]bool) { for key, count := range w.resourceHigh { if seenHigh[key] { continue } if count >= consecutiveTicksBeforeAlert { w.send(ctx, loadNotifier(), fmt.Sprintf( "🟢 [%s] pod %s : usage CPU/mémoire revenu à la normale", key.namespace, key.pod)) } delete(w.resourceHigh, key) } } type pvcKey struct{ namespace, pvc string } // kubeletSummary : sous-ensemble de l'API kubelet /stats/summary (voir // k8s.io/kubelet/pkg/apis/stats/v1alpha1.Summary sur // github.com/kubernetes/kubelet) utilisé pour lire l'usage réel des volumes // (PVC) montés sur un nœud — aucune métrique de ce type n'est exposée par // metrics-server, qui ne couvre que CPU/mémoire. Nécessite la permission // "get" sur le sous-type "nodes/proxy" pour le compte utilisé par l'API // (aucun ClusterRole scoping n'existe dans ce repo à ce jour — vérifier si // un jour le control-plane tourne avec un ServiceAccount restreint). type kubeletSummary struct { Pods []struct { PodRef struct { Namespace string `json:"namespace"` } `json:"podRef"` Volume []struct { CapacityBytes *uint64 `json:"capacityBytes,omitempty"` UsedBytes *uint64 `json:"usedBytes,omitempty"` PVCRef *struct { Name string `json:"name"` Namespace string `json:"namespace"` } `json:"pvcRef,omitempty"` } `json:"volume,omitempty"` } `json:"pods"` } // fetchNodeSummary récupère les stats kubelet d'un nœud via le proxy API // server (équivalent de `kubectl get --raw /api/v1/nodes//proxy/stats/summary`). func (w *Watcher) fetchNodeSummary(ctx context.Context, nodeName string) (*kubeletSummary, error) { raw, err := w.k8sClient.CoreV1().RESTClient().Get(). Resource("nodes"). Name(nodeName). SubResource("proxy"). Suffix("stats/summary"). Do(ctx).Raw() if err != nil { return nil, err } var summary kubeletSummary if err := json.Unmarshal(raw, &summary); err != nil { return nil, err } return &summary, nil } // checkPVCUsage alerte si un volume (PVC) d'un namespace actif dépasse // pvcFullThresholdPct de remplissage pendant consecutiveTicksBeforeAlert // ticks. nodeNames : nœuds hébergeant au moins un pod Running d'un namespace // actif (voir tick) — on n'interroge que ceux-là, pas tout le cluster. func (w *Watcher) checkPVCUsage(ctx context.Context, loadNotifier func() Notifier, nsSet map[string]bool, nodeNames map[string]bool) { seenHigh := make(map[pvcKey]bool) for node := range nodeNames { summary, err := w.fetchNodeSummary(ctx, node) if err != nil { log.Printf("alerts: stats kubelet indisponibles pour le nœud %s: %v", node, err) continue } for _, pod := range summary.Pods { if !nsSet[pod.PodRef.Namespace] { continue } for _, vol := range pod.Volume { if vol.PVCRef == nil || vol.CapacityBytes == nil || vol.UsedBytes == nil || *vol.CapacityBytes == 0 { continue } pct := int(*vol.UsedBytes * 100 / *vol.CapacityBytes) if pct < pvcFullThresholdPct { continue } key := pvcKey{namespace: vol.PVCRef.Namespace, pvc: vol.PVCRef.Name} seenHigh[key] = true w.pvcHigh[key]++ if w.pvcHigh[key] == consecutiveTicksBeforeAlert { w.send(ctx, loadNotifier(), fmt.Sprintf( "🟠 [%s] volume %s rempli à %d%%", key.namespace, key.pvc, pct)) } } } } for key, count := range w.pvcHigh { if seenHigh[key] { continue } if count >= consecutiveTicksBeforeAlert { w.send(ctx, loadNotifier(), fmt.Sprintf( "🟢 [%s] volume %s repassé sous %d%% de remplissage", key.namespace, key.pvc, pvcFullThresholdPct)) } delete(w.pvcHigh, key) } }