본 문서의 3.7(annotation 방식)을 실제 Go 컨트롤러로 구현·빌드·배포하고, Deployment / StatefulSet / DaemonSet / Argo Rollouts 에서 annotation만으로 자동 무중단 다운스케일됨을 EKS 1.36 환경에서 검증한 기록입니다. 아래 코드·매니페스트로 그대로 재현할 수 있습니다.
startup burst 때문에 포기하던 Guaranteed QoS를, 무중단으로 되찾는다 + noisy-neighbor 완화.
requests < limits(Burstable)로 줄 수밖에 없었고 → Guaranteed QoS 포기 → noisy-neighbor에 노출.cpu 200m/200m(req==limit, Guaranteed), startup 끝나면 cpu 50m/50m(여전히 req==limit, Guaranteed 유지)로 무중단 축소.Guaranteed는 cpu뿐 아니라 memory도 req==limit이어야 합니다. 따라서 memory는 고정(불변)하고 cpu만 축소합니다. memory 축소는 재시작(RestartContainer)을 수반할 수 있어 제외했습니다.
컨트롤러는 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 — 예시 ②).
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.modmodule 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
)