Ctrl K
ComunidadGuías…

pkg/k8score/node_ops.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package k8score

import (
	"context"
	"fmt"
	"log"
	"sort"
	"sync"
	"time"

	corev1 "k8s.io/api/core/v1"
	policyv1 "k8s.io/api/policy/v1"
	apierrors "k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/client-go/kubernetes"
)

const (
	// defaultDrainTimeout bounds a drain that only admits evictions.
	defaultDrainTimeout = 60 * time.Second
	// defaultDrainWaitTimeout bounds a drain that also waits for the evicted pods to
	// disappear. Admitting an eviction is quick; a pod then runs through its termination
	// grace period (30s by default, often minutes), so the wait needs more headroom.
	defaultDrainWaitTimeout = 2 * time.Minute
	// drainFinalCheckTimeout bounds the one pod list taken after the drain deadline has
	// passed. PDB-blocked evictions retry until that deadline, so by the time the wait
	// phase runs its context can already be spent; without a fresh look, pods that left
	// long ago would be reported as still on the node.
	drainFinalCheckTimeout = 5 * time.Second
)

// drainWaitPollInterval is how often the wait phase re-lists the node's pods to see which
// evicted pods are gone. A package var so tests can shorten it.
var drainWaitPollInterval = 2 * time.Second

// DrainOptions configures a node drain operation.
type DrainOptions struct {
	IgnoreDaemonSets   bool          // Skip DaemonSet-managed pods (default should be true)
	DeleteEmptyDirData bool          // Allow draining pods that use emptyDir volumes
	Force              bool          // Evict pods not managed by a controller
	GracePeriodSeconds *int64        // Override pod termination grace period
	Timeout            time.Duration // Overall deadline for the drain (evictions and, if enabled, the wait)
	// WaitForDeletion makes DrainNode wait for the evicted pods to actually disappear before
	// returning, matching kubectl drain. Without it, DrainNode returns as soon as the Eviction
	// API has accepted every eviction, which does not mean the pods have finished terminating.
	WaitForDeletion bool
}

// DrainResult reports what happened during a drain operation.
type DrainResult struct {
	EvictedPods []string               `json:"evictedPods"`
	SkippedPods []PodDrainDecision     `json:"skippedPods,omitempty"` // pods left alone and why
	Errors      []string               `json:"errors,omitempty"`
	Outcome     DrainCompletionOutcome `json:"outcome"`
	// WaitedForDeletion is true when the drain waited for the evicted pods to actually be
	// deleted. When false, EvictedPods lists pods whose eviction was merely accepted and
	// which may still be terminating.
	WaitedForDeletion bool `json:"waitedForDeletion"`
	// PendingPods were observed on the node in the final successful verification.
	PendingPods []string `json:"pendingPods,omitempty"`
	// UnconfirmedPods could not be checked in the final verification.
	UnconfirmedPods   []string `json:"unconfirmedPods,omitempty"`
	VerificationError string   `json:"verificationError,omitempty"`
}

// drainedPod identifies a pod whose eviction was accepted, so the wait phase can tell an
// evicted pod that is gone from a namesake recreated under a new UID.
type drainedPod struct {
	namespace string
	name      string
	uid       types.UID
}

// CordonNode marks a node as unschedulable.
// Uses strategic merge patch (idempotent, no read-modify-write race).
func CordonNode(ctx context.Context, client kubernetes.Interface, nodeName string) error {
	patch := []byte(`{"spec":{"unschedulable":true}}`)
	_, err := client.CoreV1().Nodes().Patch(ctx, nodeName, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
	if err != nil {
		return fmt.Errorf("cordon node: %w", err)
	}
	return nil
}

// UncordonNode marks a node as schedulable.
// Uses strategic merge patch (idempotent, no read-modify-write race).
func UncordonNode(ctx context.Context, client kubernetes.Interface, nodeName string) error {
	patch := []byte(`{"spec":{"unschedulable":null}}`)
	_, err := client.CoreV1().Nodes().Patch(ctx, nodeName, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
	if err != nil {
		return fmt.Errorf("uncordon node: %w", err)
	}
	return nil
}

// drainTimeout resolves the overall drain deadline, honouring an explicit Timeout and
// otherwise choosing a default sized for what the drain does: admitting evictions is quick,
// waiting for pods to finish terminating is not.
func drainTimeout(opts DrainOptions) time.Duration {
	switch {
	case opts.Timeout != 0:
		return opts.Timeout
	case opts.WaitForDeletion:
		return defaultDrainWaitTimeout
	default:
		return defaultDrainTimeout
	}
}

// DrainNode cordons the node and evicts all eligible pods.
func DrainNode(ctx context.Context, client kubernetes.Interface, nodeName string, opts DrainOptions) (*DrainResult, error) {
	opts.Timeout = drainTimeout(opts)

	// Cordon first to prevent new pods from being scheduled
	if err := CordonNode(ctx, client, nodeName); err != nil {
		return nil, fmt.Errorf("cordon before drain: %w", err)
	}

	// List all pods on this node
	podList, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{
		FieldSelector: "spec.nodeName=" + nodeName,
	})
	if err != nil {
		return nil, fmt.Errorf("list pods on node: %w", err)
	}

	// Decide per pod with the same classifier the drain plan uses. PDBs are not
	// pre-checked here: the Eviction API is authoritative and evictPod retries on 429.
	result := &DrainResult{}
	var toEvict []corev1.Pod
	for _, pod := range podList.Items {
		decision := ClassifyPodForDrain(pod, opts, nil)
		if decision.Outcome == DrainOutcomeSkip {
			result.SkippedPods = append(result.SkippedPods, decision)
			continue
		}
		toEvict = append(toEvict, pod)
	}
	logSkippedPods(nodeName, result.SkippedPods)

	if len(toEvict) == 0 {
		result.Outcome = result.CompletionOutcome()
		return result, nil
	}

	drainCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
	defer cancel()

	evicted := evictPods(drainCtx, client, toEvict, opts.GracePeriodSeconds, result)

	// An accepted eviction is not a departed pod: the kubelet still runs it through its
	// termination grace period. Without this wait a caller that reboots on "drained"
	// SIGKILLs pods mid-shutdown, the exact thing draining is meant to avoid.
	if opts.WaitForDeletion && len(evicted) > 0 {
		result.WaitedForDeletion = true
		remaining, verificationErr := waitForPodsGone(ctx, drainCtx, client, nodeName, evicted)
		if verificationErr != nil {
			result.UnconfirmedPods = pendingPodNames(remaining)
			result.VerificationError = verificationErr.Error()
		} else {
			result.PendingPods = pendingPodNames(remaining)
		}
	}

	result.Outcome = result.CompletionOutcome()
	return result, nil
}

func logSkippedPods(nodeName string, skipped []PodDrainDecision) {
	if len(skipped) == 0 {
		return
	}
	names := make([]string, 0, len(skipped))
	for _, d := range skipped {
		names = append(names, d.Namespace+"/"+d.Name)
	}
	log.Printf("[node-ops] Drain %s: skipping %d pods: %v", nodeName, len(names), names)
}

// evictPods evicts every pod in toEvict with bounded concurrency, recording each outcome on
// result, and returns the pods whose eviction was accepted so the caller can wait them out.
func evictPods(ctx context.Context, client kubernetes.Interface, toEvict []corev1.Pod, gracePeriod *int64, result *DrainResult) []drainedPod {
	const maxConcurrent = 5
	sem := make(chan struct{}, maxConcurrent)
	var mu sync.Mutex
	var wg sync.WaitGroup

	var evicted []drainedPod
	for i := range toEvict {
		pod := toEvict[i]
		wg.Add(1)
		go func() {
			defer wg.Done()

			err := evictPod(ctx, client, pod, gracePeriod, sem)
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				result.Errors = append(result.Errors, fmt.Sprintf("%s/%s: %v", pod.Namespace, pod.Name, err))
			} else {
				result.EvictedPods = append(result.EvictedPods, pod.Namespace+"/"+pod.Name)
				evicted = append(evicted, drainedPod{namespace: pod.Namespace, name: pod.Name, uid: pod.UID})
			}
		}()
	}
	wg.Wait()
	return evicted
}

// pendingPodNames turns the pods still present after a wait into a sorted namespace/name list.
func pendingPodNames(pods []drainedPod) []string {
	if len(pods) == 0 {
		return nil
	}
	names := make([]string, 0, len(pods))
	for _, p := range pods {
		names = append(names, p.namespace+"/"+p.name)
	}
	sort.Strings(names)
	return names
}

// A changed UID confirms the original pod was deleted, even if a controller reused its name.
// The final read outlives the drain deadline so PDB retries cannot hide completed deletions,
// but it remains bounded by the caller's cancellation and drainFinalCheckTimeout.
func waitForPodsGone(callerCtx, drainCtx context.Context, client kubernetes.Interface, nodeName string, evicted []drainedPod) ([]drainedPod, error) {
	remaining := make(map[types.UID]drainedPod, len(evicted))
	for _, p := range evicted {
		remaining[p.uid] = p
	}

	if pollUntilPodsGone(drainCtx, client, nodeName, remaining) {
		return nil, nil
	}
	if err := callerCtx.Err(); err != nil {
		return mapValues(remaining), err
	}

	finalCtx, cancel := context.WithTimeout(callerCtx, drainFinalCheckTimeout)
	defer cancel()
	if gone, err := pruneDepartedPods(finalCtx, client, nodeName, remaining); err != nil {
		return mapValues(remaining), err
	} else if gone {
		return nil, nil
	}
	return mapValues(remaining), nil
}

// pollUntilPodsGone re-lists the node's pods until remaining is empty (returning true) or ctx
// is done (returning false). It makes no attempt at all when ctx is already done.
func pollUntilPodsGone(ctx context.Context, client kubernetes.Interface, nodeName string, remaining map[types.UID]drainedPod) bool {
	if ctx.Err() != nil {
		return false
	}
	if gone, _ := pruneDepartedPods(ctx, client, nodeName, remaining); gone {
		return true
	}

	ticker := time.NewTicker(drainWaitPollInterval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return false
		case <-ticker.C:
			if gone, _ := pruneDepartedPods(ctx, client, nodeName, remaining); gone {
				return true
			}
		}
	}
}

// pruneDepartedPods drops from remaining every pod no longer present on the node and reports
// whether the set is now empty. A failed list leaves the set untouched, so a transient error
// costs one poll interval rather than a false "still here".
func pruneDepartedPods(ctx context.Context, client kubernetes.Interface, nodeName string, remaining map[types.UID]drainedPod) (bool, error) {
	list, err := client.CoreV1().Pods("").List(ctx, metav1.ListOptions{
		FieldSelector: "spec.nodeName=" + nodeName,
	})
	if err != nil {
		return false, err
	}
	present := make(map[types.UID]struct{}, len(list.Items))
	for i := range list.Items {
		present[list.Items[i].UID] = struct{}{}
	}
	for uid := range remaining {
		if _, ok := present[uid]; !ok {
			delete(remaining, uid)
		}
	}
	return len(remaining) == 0, nil
}

// mapValues returns the values of m in unspecified order.
func mapValues(m map[types.UID]drainedPod) []drainedPod {
	out := make([]drainedPod, 0, len(m))
	for _, p := range m {
		out = append(out, p)
	}
	return out
}

func hasLocalStorage(pod corev1.Pod) bool {
	for _, vol := range pod.Spec.Volumes {
		if vol.EmptyDir != nil {
			return true
		}
	}
	return false
}

// evictPod evicts a single pod, retrying on PDB conflicts until the context deadline.
// The concurrency slot is held only around each Eviction call, never across the
// backoff sleep: a handful of PDB-blocked pods sleeping in their retry loops would
// otherwise occupy every slot until the shared deadline, and pods with no budget at
// all would time out without a single eviction attempt.
func evictPod(ctx context.Context, client kubernetes.Interface, pod corev1.Pod, gracePeriod *int64, sem chan struct{}) error {
	eviction := &policyv1.Eviction{
		ObjectMeta: metav1.ObjectMeta{
			Name:      pod.Name,
			Namespace: pod.Namespace,
		},
	}
	if gracePeriod != nil {
		eviction.DeleteOptions = &metav1.DeleteOptions{
			GracePeriodSeconds: gracePeriod,
		}
	}

	backoff := 500 * time.Millisecond
	maxBackoff := 5 * time.Second
	attempted := false

	deadlineErr := func() error {
		if attempted {
			return fmt.Errorf("timed out waiting for PDB to allow eviction: %w", ctx.Err())
		}
		return fmt.Errorf("drain deadline passed before this pod could be attempted: %w", ctx.Err())
	}

	for {
		select {
		case sem <- struct{}{}:
		case <-ctx.Done():
			return deadlineErr()
		}
		// The select above can pick the slot even when cancellation is also ready.
		if ctx.Err() != nil {
			<-sem
			return deadlineErr()
		}
		err := client.PolicyV1().Evictions(pod.Namespace).Evict(ctx, eviction)
		<-sem
		attempted = true

		if err == nil {
			return nil
		}

		// Pod already gone
		if apierrors.IsNotFound(err) {
			return nil
		}

		// PDB blocking eviction — retry with backoff
		if apierrors.IsTooManyRequests(err) {
			select {
			case <-ctx.Done():
				return fmt.Errorf("timed out waiting for PDB to allow eviction: %w", ctx.Err())
			case <-time.After(backoff):
				backoff *= 2
				if backoff > maxBackoff {
					backoff = maxBackoff
				}
				continue
			}
		}

		return fmt.Errorf("evict: %w", err)
	}
}