본 문서의 3.7(annotation 방식)을 실제 Go 컨트롤러로 구현·빌드·배포하고, Deployment / StatefulSet / DaemonSet / Argo Rollouts 에서 annotation만으로 자동 무중단 다운스케일됨을 EKS 1.36 환경에서 검증한 기록입니다. 아래 코드·매니페스트로 그대로 재현할 수 있습니다.

0. 핵심 가치 (TL;DR)

startup burst 때문에 포기하던 Guaranteed QoS를, 무중단으로 되찾는다 + noisy-neighbor 완화.

Guaranteed는 cpu뿐 아니라 memory도 req==limit이어야 합니다. 따라서 memory는 고정(불변)하고 cpu만 축소합니다. memory 축소는 재시작(RestartContainer)을 수반할 수 있어 제외했습니다.


1. 아키텍처

컨트롤러는 Pod만 watch합니다. 워크로드 종류(Deployment/STS/DS/Rollout)를 알 필요가 없으므로, 동일 코드가 모든 종류에 그대로 적용됩니다.

Pod 생성 (startup용 큰 CPU 200m/200m = Guaranteed)
  → 컨트롤러가 pod.status.containerStatuses[].started 를 watch
  → started:true 감지 (= startup probe 통과)
  → pods/resize 서브리소스로 steady(50m/50m) in-place 패치 (무중단)
  → resize.example.com/resized=true 마커로 멱등 처리
annotation 의미
resize.example.com/enabled: "true" 이 Pod 를 resize 대상으로 표시
resize.example.com/trigger StartupProbePassed(기본) / Ready / Delay
resize.example.com/delay-seconds trigger=Delay 일 때 컨테이너 기동 후 대기 초
resize.example.com/steady-resources steady 단계 리소스 JSON (컨테이너명 → requests/limits)
resize.example.com/resized 컨트롤러가 처리 완료 시 부착하는 멱등 마커

in-place resize 가 동작하려면 Pod spec 에 resizePolicy 가 있어야 합니다(갈래 A: 작성자가 명시). 1.36 GA된 Mutating Admission Policy 로 생성 시 주입하면 webhook 없이 자동화할 수 있습니다(갈래 B — 예시 ②).


2. 컨트롤러 전체 코드

client-go SharedInformer 기반 단일 바이너리(~180줄). 멱등 마커가 informer 캐시에 반영되기 전 중복 패치되는 race 를 sync.Map 으로 방지합니다.

main.go

// pod-resizer — annotation 기반 무중단 in-place 다운스케일 컨트롤러 (갈래 A).
// 워크로드 종류(Deployment/StatefulSet/DaemonSet/Rollout)를 알 필요가 없다.
// Pod 만 watch 하다, startup 통과 시점에 pods/resize 서브리소스로 steady 리소스로
// 무중단 패치한다. req==limit 을 양쪽 단계에서 유지하면 Guaranteed QoS 가 보존된다(KEP-1287).
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"strconv"
	"sync"
	"time"

	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	"k8s.io/client-go/informers"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/cache"
)

const (
	annEnabled = "resize.example.com/enabled"
	annTrigger = "resize.example.com/trigger"
	annDelay   = "resize.example.com/delay-seconds"
	annSteady  = "resize.example.com/steady-resources"
	annResized = "resize.example.com/resized"
)

// steady-resources annotation 의 JSON 형식:
//   {"app":{"requests":{"cpu":"50m"},"limits":{"cpu":"50m"}}}
type resVals struct {
	Requests map[string]string `json:"requests,omitempty"`
	Limits   map[string]string `json:"limits,omitempty"`
}

var clientset *kubernetes.Clientset

// processed: 이미 resize 한 Pod(UID) 기억 → annotation 마커 전파 전 중복 패치 race 차단.
var processed sync.Map

func main() {
	cfg, err := rest.InClusterConfig()
	if err != nil {
		log.Fatalf("in-cluster config: %v", err)
	}
	clientset, err = kubernetes.NewForConfig(cfg)
	if err != nil {
		log.Fatalf("clientset: %v", err)
	}

	factory := informers.NewSharedInformerFactory(clientset, 15*time.Second)
	podInformer := factory.Core().V1().Pods().Informer()
	podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{
		AddFunc:    func(obj interface{}) { handle(obj) },
		UpdateFunc: func(_, obj interface{}) { handle(obj) },
	})

	stop := make(chan struct{})
	defer close(stop)
	log.Printf("pod-resizer starting; watching pods annotated %s=true", annEnabled)
	factory.Start(stop)
	factory.WaitForCacheSync(stop)
	log.Printf("informer cache synced; ready")
	select {}
}

func handle(obj interface{}) {
	pod, ok := obj.(*corev1.Pod)
	if !ok {
		return
	}
	a := pod.Annotations
	if a == nil || a[annEnabled] != "true" || a[annResized] == "true" {
		return
	}
	if pod.DeletionTimestamp != nil || pod.Status.Phase != corev1.PodRunning {
		return
	}

	trigger := a[annTrigger]
	if trigger == "" {
		trigger = "StartupProbePassed"
	}
	if !triggerMet(pod, trigger, a[annDelay]) {
		return
	}

	steady := map[string]resVals{}
	if err := json.Unmarshal([]byte(a[annSteady]), &steady); err != nil {
		log.Printf("ERROR %s/%s: bad %s: %v", pod.Namespace, pod.Name, annSteady, err)
		return
	}
	patch := buildResizePatch(steady)
	if patch == nil {
		return
	}
	pb, _ := json.Marshal(patch)

	// 원자적 1회 처리: 이미 처리 중/완료면 즉시 반환 (중복 패치 방지)
	key := string(pod.UID)
	if _, loaded := processed.LoadOrStore(key, true); loaded {
		return
	}

	if _, err := clientset.CoreV1().Pods(pod.Namespace).Patch(
		context.TODO(), pod.Name, types.StrategicMergePatchType, pb,
		metav1.PatchOptions{}, "resize"); err != nil {
		processed.Delete(key) // 실패 시 재시도 허용
		log.Printf("ERROR %s/%s: resize patch failed: %v", pod.Namespace, pod.Name, err)
		return
	}
	log.Printf("RESIZED %s/%s [%s] trigger=%s patch=%s",
		pod.Namespace, pod.Name, ownerKind(pod), trigger, string(pb))

	// 멱등 마커
	mark := []byte(fmt.Sprintf(`{"metadata":{"annotations":{%q:"true"}}}`, annResized))
	if _, err := clientset.CoreV1().Pods(pod.Namespace).Patch(
		context.TODO(), pod.Name, types.MergePatchType, mark, metav1.PatchOptions{}); err != nil {
		log.Printf("WARN %s/%s: marker patch failed: %v", pod.Namespace, pod.Name, err)
	}
}

func triggerMet(pod *corev1.Pod, trigger, delayStr string) bool {
	switch trigger {
	case "Ready":
		for _, c := range pod.Status.Conditions {
			if c.Type == corev1.PodReady {
				return c.Status == corev1.ConditionTrue
			}
		}
		return false
	case "Delay":
		delay, _ := strconv.Atoi(delayStr)
		for _, cs := range pod.Status.ContainerStatuses {
			if cs.State.Running != nil {
				return time.Since(cs.State.Running.StartedAt.Time) >= time.Duration(delay)*time.Second
			}
		}
		return false
	default: // StartupProbePassed: 모든 컨테이너 started==true
		if len(pod.Status.ContainerStatuses) == 0 {
			return false
		}
		for _, cs := range pod.Status.ContainerStatuses {
			if cs.Started == nil || !*cs.Started {
				return false
			}
		}
		return true
	}
}

func buildResizePatch(steady map[string]resVals) map[string]interface{} {
	var containers []map[string]interface{}
	for name, rv := range steady {
		res := map[string]interface{}{}
		if len(rv.Requests) > 0 {
			res["requests"] = rv.Requests
		}
		if len(rv.Limits) > 0 {
			res["limits"] = rv.Limits
		}
		containers = append(containers, map[string]interface{}{"name": name, "resources": res})
	}
	if len(containers) == 0 {
		return nil
	}
	return map[string]interface{}{"spec": map[string]interface{}{"containers": containers}}
}

func ownerKind(pod *corev1.Pod) string {
	if len(pod.OwnerReferences) > 0 {
		return pod.OwnerReferences[0].Kind
	}
	return "Pod"
}

go.mod

module pod-resizer

go 1.23

require (
	k8s.io/api v0.31.4
	k8s.io/apimachinery v0.31.4
	k8s.io/client-go v0.31.4
)