본문 바로가기
DevOps, SRE/GitOps

Argo Rollouts Deep Dive

by Ramos 2026. 6. 11.
 

안다고 생각했던 ArgoCD를 원리부터 다시 확인해보기

우리 플랫폼의 배포는 전부 ArgoCD 위에서 돈다. ArgoCD를 깐 건 내가 아니지만, GitOps도 ArgoCD도 큰 그림은 대부분 알고 매일 그 위에서 배포를 다뤄왔다. 다만 "OutOfSync면 이게 장애인가?" 같은 질문에

ramos-log.tistory.com

지난 글에서 ArgoCD를 원리부터 다시 팠다. 그 끝에 "다음은 Argo Rollouts"라고 적어뒀는데, 이 글이 그 후속이다. ArgoCD가 "무엇을 언제 배포하나(Git이 곧 단일 진실원천)"를 푸는 도구라면, Rollouts는 "한 워크로드를 어떻게 점진적으로 내보내나"를 푸는, 층위가 다른 도구다. 우리 플랫폼의 검증(alpha) 클러스터에는 Argo Rollouts가 이미 깔려 돌고 있다. 다만 내가 도입한 게 아니다. 매니페스트를 열면 setWeight: 20 한 줄이 보이는데, "이 한 줄이 실제로 트래픽을 어떻게 가르지?"라는 질문에 익숙함으로만 답하고 있던 게 걸렸다. 그래서 기본 Deployment의 한계에서 출발해, Rollouts가 그 빈칸을 어떤 메커니즘으로 메우는지, 그리고 어디서 사고가 나는지를 처음부터 다시 짚었다. 이 글은 그 기록이다.

Intro

  • Rollout은 Deployment를 대체하는 CRD다. 컨트롤러가 신/구 ReplicaSet 2벌과 Service의 selector를 직접 조작해 트래픽을 점진 전환한다. 이게 본질이고 Blue-Green이든 Canary든 나머지는 전부 그 변주다.
  • 매일 보던 setWeight: 20 한 줄의 정체를 그제서야 원리로 답했다. 트래픽 전환은 결국 Service selector의 rollouts-pod-template-hash 라벨 값을 바꾸는 것, 그 한 줄로 환원된다. 롤백이 빠른 이유도 같다. 구버전 ReplicaSet을 죽이지 않고 살려둬서 selector만 되돌리면 끝이기 때문이다.
  • 가장 크게 바로잡은 오해: trafficRouting 없는 setWeight는 트래픽 비율이 아니라 파드 개수 비율의 근사치일 뿐이다. replica가 1개면 setWeight: 20은 실제로 0개 아니면 1개라 의미가 없다. 진짜 가중치 카나리아는 Ingress나 Istio 같은 라우터가 있어야 성립한다.
  • Degraded가 곧 장애는 아니다. 이건 지난 ArgoCD 글에서 Sync와 Health를 두 축으로 분리하며 얻은 교훈과 정확히 같은 결이다. abort된 Rollout은 stable로 즉시 복귀하므로 서비스는 멀쩡한데 상태 라벨만 빨갛게 떠 있을 수 있다.
  • 가장 흔한 사고는 도구가 아니라 사람이 만든다. autoPromotionEnabled: false인데 아무도 promote를 안 해서 영원히 Paused, 또는 abort 후 retry를 안 해서 신버전이 영영 안 올라가는 케이스. "배포했는데 신버전이 안 떠요"의 1순위 원인이다.
  • AnalysisTemplate 없이는 "Progressive Delivery"가 아니다. 지표 기반 자동 abort가 붙어야 비로소 자동화된 점진 배포고, 그게 없으면 그냥 예쁜 단계 표시가 달린 수동 게이트일 뿐이다. 우리 검증 클러스터의 데모도 아직 여기까지는 안 와 있었다.

출발점 : setWeight: 20 한 줄의 정체

검증 클러스터의 Rollout 매니페스트를 열면 이런 단계 정의가 보인다.

steps:
- setWeight: 20
- pause: {}
- setWeight: 40
- pause: { duration: 10 }

매일 보던 모양인데, 막상 "이 setWeight: 20이 실제 트래픽을 어떻게 20%로 가르는가?"를 원리로 설명하라고 하면 말문이 막혔다. ReplicaSet을 새로 만든다는 건 알겠는데, 트래픽이 정확히 어느 지점에서 갈라지는지, replica가 1개뿐인 이 데모에서 20%라는 게 무슨 의미인지가 흐릿했다.

지난 글에서 ArgoCD를 파고 나서 한 가지 습관이 생겼다. 익숙하다고 넘어가던 도구를 "안에서 무슨 일이 벌어지는가"까지 내려가 보는 것. Rollouts도 그렇게 다시 봤다. 출발점은 늘 같다. 기본 Deployment만으로 안 되는 게 뭐길래 별도 CRD가 필요한가.

기본 Deployment로 안 되는 것

K8s Deployment의 기본 업데이트 전략은 RollingUpdate다. maxSurgemaxUnavailable로 "한 번에 몇 개씩 교체할지"는 조절되지만, 본질은 하나다. 한 번 시작하면 100%까지 자동으로 굴러간다.

여기서 못 하는 게 네 가지다.

  • 점진 트래픽 분배 : "신버전에 우선 5%만 흘려보고 싶다"가 안 된다. 파드 비율이 곧 트래픽 비율로 강제되고(Service가 라운드로빈), 중간에 멈춰서 일부만 노출한 채 관찰하는 게 불가능하다.
  • 관찰 후 결정(게이트) : 배포 도중 에러율이나 지연을 보고 "여기서 멈춰, 더 갈지 정하자"가 없다. probe가 통과하면 그냥 다음으로 넘어간다.
  • 빠른 롤백 : 문제를 발견해도 구버전 ReplicaSet은 이미 scale-down 중이라 되돌리는 데 시간이 걸린다.
  • 지표 기반 자동 판단 : Prometheus 지표를 보고 자동으로 중단하거나 승급하는 훅이 없다.

Argo Rollouts는 Deployment와 거의 같은 spec을 갖는 Rollout이라는 CRD를 제공하고, 전용 컨트롤러가 이 리소스를 감시한다. 채우는 빈칸은 정확히 위 네 가지다.

spec이 거의 동일하다는 건 실무에서 중요하다. 기존 DeploymentRollout으로 바꾸는 비용이 낮다는 뜻이기 때문이다. 다른 점은 spec.strategyblueGreen 또는 canary를 쓴다는 것 하나다.

핵심 메커니즘 : 트래픽 전환 = selector 해시 한 줄

원리로 내려가 보니, 두 전략(Blue-Green/Canary)을 떠받치는 메커니즘은 의외로 단순한 한 줄이었다.

Rollout 컨트롤러는 새 버전이 들어오면 새 ReplicaSet을 만들고, Service의 selector를 조작해서 트래픽을 가른다. 그 매개체가 rollouts-pod-template-hash 라벨이다.

  • 파드 템플릿이 바뀌면 새 해시가 생기고, 그에 따라 새 ReplicaSet이 만들어진다(revision +1).
  • 각 Service의 selector.rollouts-pod-template-hash어느 ReplicaSet(버전)으로 트래픽을 보낼지 결정한다.
  • 트래픽 전환 = Service selector의 해시 값 변경. 이 한 줄이 Blue-Green과 Canary 양쪽의 공통 메커니즘이다.

이 그림을 그리고 나서야 "롤백이 빠르다"는 말의 정체가 잡혔다. 구버전 ReplicaSet을 revisionHistoryLimit 범위 안에서 scale=0으로 살려두기 때문이다. 파드를 새로 띄울 필요 없이 selector와 replica 수만 되돌리면 끝이라, abort가 즉각적이다.

workloadRef 패턴과 replicas: 0 함정

Rollout을 작성하는 방식은 두 가지다. spec.template을 직접 넣는 인라인 방식(공식 문서 기본형)과, strategy만 정의하고 파드 스펙은 기존 Deployment를 가리키는 workloadRef 방식. 우리 검증 클러스터의 데모는 전부 후자다.

# Rollout 은 strategy 와 workloadRef 만 정의
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: canary-demo
spec:
  replicas: 1
  revisionHistoryLimit: 1
  workloadRef:                 # template 대신 기존 Deployment 참조
    apiVersion: apps/v1
    kind: Deployment
    name: canary-demo
  strategy:
    canary:
      steps: [ ... ]
---
# 파드 스펙은 Deployment 에, 단 replicas: 0
apiVersion: apps/v1
kind: Deployment
metadata:
  name: canary-demo
  labels: { app: canary-demo }
spec:
  replicas: 0                  # 0! Rollout 이 스케일링 주체를 가져감
  selector:
    matchLabels: { app: canary-demo }
  template:
    metadata:
      labels: { app: canary-demo }
    spec:
      containers:
      - name: canary-demo
        image: <internal-registry>/argoproj/rollouts-demo:purple
        ports: [ { name: http, containerPort: 8080 } ]
        resources:
          requests: { memory: 32Mi, cpu: 5m }

여기 함정이 하나 있다. workloadRef가 가리키는 Deploymentreplicas는 반드시 0이어야 한다. Rollout이 ReplicaSet 스케일링의 주체를 가져가기 때문이다. 만약 Deployment가 replicas > 0이면 같은 파드를 두 컨트롤러가 띄우려고 다툰다. 데모도 정확히 replicas: 0으로 두고 있는데, 이게 실수가 아니라 의도라는 걸 알아야 매니페스트가 읽힌다.

두 전략 : Blue-Green과 Canary

Blue-Green : 풀세트 2벌, 한 방에 스위치

구버전(Blue) 풀세트와 신버전(Green) 풀세트를 동시에 띄워두고, 준비되면 트래픽을 한 번에 스위치한다.

핵심 필드는 이렇다.

  • activeService : 실사용자 트래픽이 향하는 Service.
  • previewService : 신버전을 미리 검증하는 Service(내부 QA용).
  • autoPromotionEnabled : true면 신버전이 준비되는 즉시(옵션상 대기 후) 자동 스위치, false사람이 명시적으로 promote할 때까지 Paused로 대기. "왜 안 넘어가지?"의 가장 흔한 원인이다.
  • scaleDownDelaySeconds : 스위치 후 구버전을 곧장 죽이지 않고 N초 유지해, 문제 시 즉시 롤백할 여지를 남긴다.

검증 클러스터의 실제 매니페스트는 이런 형태다.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: bluegreen-demo
spec:
  replicas: 1
  revisionHistoryLimit: 1
  workloadRef:
    apiVersion: apps/v1
    kind: Deployment
    name: bluegreen-demo
  strategy:
    blueGreen:
      autoPromotionEnabled: false                 # 사람이 promote 해야 전환
      activeService: bluegreen-demo-active-svc     # 실트래픽
      previewService: bluegreen-demo-preview-svc   # 내부 검증

처음에 헷갈렸던 게 하나 있다. active와 preview 두 Service의 selector가 매니페스트상으로는 app: bluegreen-demo똑같다. "그럼 둘이 어떻게 갈라지지?" 싶었는데, §3에서 본 메커니즘이 답이었다. 컨트롤러가 런타임에 여기에 rollouts-pod-template-hash를 덧붙여서, active는 stable RS로, preview는 신 RS로 가른다. 그래서 매니페스트만 보면 둘이 같아 보이는 게 정상이다.

다만 이 데모는 scaleDownDelaySeconds를 명시하지 않아 기본값(30s)만 적용된다. 빠른 롤백 여지가 30초뿐이라는 뜻이고, 이건 뒤에서 다룰 함정 그대로다.

언제 쓰나 : 트래픽을 비율로 쪼개기 어렵거나(상태 보존 등), "검증 끝나면 한 방에 전환, 문제 시 즉시 롤백"이 필요할 때. 단점은 전환 동안 리소스가 순간 2배라는 것.

Canary : 비중을 단계적으로 올린다

신버전 파드 비중을 단계적으로(예: 20 → 40 → 60 → 80 → 100%) 올리며, 중간중간 멈춰서 관찰한다.

stepssetWeight(가중치 %)와 pause(멈춤)를 번갈아 정의한다. 여기서 pause의 두 형태를 구분하는 게 운영의 핵심이다.

  • pause: {} (duration 없음) : 무기한 정지. 사람이 promote해야 다음 단계로 넘어간다.
  • pause: { duration: 10 } : 10초(또는 10m) 후 자동 진행.

매일 보던 그 한 줄의 함정 : trafficRouting 없는 Canary

이게 이 글을 쓰게 만든 지점이다. trafficRouting을 지정하지 않으면 setWeight는 트래픽 비율이 아니라 파드 개수 비율의 근사치로만 동작한다.

검증 클러스터의 데모 canary가 바로 그 살아있는 표본이었다.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: canary-demo
spec:
  replicas: 1                  # replica 1개 + trafficRouting 없음
  revisionHistoryLimit: 1
  workloadRef:
    apiVersion: apps/v1
    kind: Deployment
    name: canary-demo
  strategy:
    canary:
      steps:
      - setWeight: 20
      - pause: {}              # duration 없음 = 무기한 정지, 사람이 promote
      - setWeight: 40
      - pause: { duration: 10 }
      - setWeight: 60
      - pause: { duration: 10 }
      - setWeight: 80
      - pause: { duration: 10 }

canaryService, stableService, trafficRouting하나도 없다. replica도 1개라 setWeight: 20은 실제로는 0개 아니면 1개로만 떨어진다. 즉 이 데모는 정밀 가중치 카나리아가 아니라 단계 진행/pause/promote 흐름을 보여주는 용도다. 게다가 맨 앞 pause: {} 때문에, 배포는 setWeight: 20 단계에서 사람이 promote할 때까지 멈춘다. 매일 보던 setWeight: 20이 "트래픽 20%"가 아니었다는 게 이 글에서 가장 크게 바로잡은 오해다.

그럼 "제대로 된" 카나리아는 어떻게 생겼나. 같은 클러스터의 샘플 서비스 dev 오버레이는 Istio 가중치 라우팅을 쓴다.

apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: sample-service-ro
spec:
  replicas: 1
  revisionHistoryLimit: 1
  workloadRef:
    apiVersion: apps/v1
    kind: Deployment
    name: sample-service-dp
  strategy:
    canary:
      stableService: sample-service-svc          # 구버전 트래픽
      canaryService: sample-service-canary-svc    # 신버전 트래픽
      trafficRouting:
        istio:
          virtualService:
            name: sample-vs.istio-system   # VS 의 route weight 를 컨트롤러가 조작
            routes:
            - sample-service
      steps:
      - setWeight: 25
      - pause: { duration: 60s }
      - setWeight: 100

차이가 선명하다. stableServicecanaryService 두 개를 두고, trafficRouting.istioVirtualService의 route weight를 컨트롤러가 직접 조작한다. 그래서 replica가 적어도 setWeight: 25가 실제 트래픽 25%로 반영된다. 정리하면 이렇다.

방식 가중치 제어 단위 특징
trafficRouting 없음 파드 개수 비율(근사) 가장 단순. replica 적으면 부정확
Nginx Ingress Ingress canary 어노테이션 canary-by-header로 특정 헤더만 신버전 라우팅 가능
Istio VirtualService VS route weight 세밀한 % 제어, 메시 필요

여기서 한 가지 덧붙이면, Nginx의 canary-by-header 패턴은 가중치 없이도 "특정 헤더를 가진 요청만 신버전으로" 보내는 대상 한정 카나리아를 만든다. QA 역할 헤더가 붙은 요청만 신버전으로 흘리는 식이다. 우리 데모엔 적용돼 있지 않지만, 가중치 카나리아와는 또 다른 결의 점진 배포라 알아두면 선택지가 넓어진다.

상태 머신 : Degraded가 곧 장애는 아니다

운영의 핵심은 Rolloutstatus.phase를 읽는 것이다. 그리고 여기서 지난 ArgoCD 글과 정확히 같은 깨달음이 반복됐다. 상태 라벨 하나만 보고 놀라지 말고 의미를 분리해서 읽어라.

phase 의미
Progressing 롤아웃 진행 중(새 RS 띄우는 중)
Paused 단계에서 멈춤. pause: {} 또는 autoPromotion: false 대기
Healthy 최신 버전이 100% 안정 서빙 중(완료)
Degraded 실패/중단(RolloutAborted 등)

세 가지 제어 동작을 구분해야 한다.

  • promote : 현재 pause/preview 단계를 통과시켜 다음으로. promote --full은 남은 단계를 전부 건너뛰고 완료한다.
  • abort : 진행 중단 + stable로 즉시 복귀. 신버전 RS는 scale=0으로 내려간다.
  • retry : abort된 롤아웃을 다시 진행.

여기서 핵심은 abort의 의미다. abort하면 phase는 Degraded로 빨갛게 뜨지만, 트래픽은 stable로 복귀해 서비스 자체는 멀쩡하다. 이건 지난 글에서 ArgoCD의 Sync와 Health를 두 축으로 분리하며 얻은 교훈, "OutOfSync ≠ 장애, Synced ≠ 정상"과 같은 결이다. Rollouts에서도 Degraded라는 라벨 하나가 곧 장애를 뜻하지 않는다. "신버전 전개가 멈췄다"와 "서비스가 죽었다"는 다른 얘기다.

사고는 도구가 아니라 사람이 만든다

원리를 잡고 나니, 실전에서 사고가 나는 지점은 대부분 도구의 버그가 아니라 운영의 빈틈이었다.

  1. autoPromotionEnabled: false + promote 망각 → Rollout이 영원히 Paused. "배포했는데 신버전이 안 떠요"의 1순위 원인이다. 의도된 수동 게이트인지, 그냥 까먹은 건지를 먼저 구분해야 한다.
  2. pause: {} (duration 없음) → 무기한 정지. CI 파이프라인이 이 지점에서 멈춰 기다리도록 설계됐는지 확인해야 한다.
  3. abort 후 방치 → stable로 복귀해 서비스는 멀쩡하지만 신버전은 영영 안 올라간다. retry나 새 배포가 필요하다. Degraded를 장애로 오인해 놀라기는 쉽지만, 정작 진짜 문제는 "아무도 retry를 안 눌렀다"는 쪽인 경우가 많다.
  4. trafficRouting 없이 setWeight 신뢰 → §4.3 그대로. replica가 적으면 가중치가 사실상 무의미하다.
  5. scaleDownDelaySeconds 미설정 → Blue-Green 스위치 직후 구버전이 빠르게 사라져 롤백 여지가 좁아진다.
  6. AnalysisTemplate 없이 "Progressive Delivery 한다"는 착각 → 다음 장에서 따로.

보강 로드맵 / 결론

원리를 한 바퀴 돌고 나니, 매니페스트의 setWeight: 20 한 줄이 이제는 "selector 해시를 조작해 트래픽을 가르는 단계 하나"로 읽힌다. 그리고 다음에 더 팔 지점이 분명해졌다.

  • AnalysisTemplate + Prometheus 자동 게이트 : 지금 우리 데모는 pause에서 사람이 promote를 누른다. 여기에 AnalysisTemplate을 붙이면 "에러율 < 1%이고 p95 < 300ms면 다음 단계, 아니면 자동 abort" 같은 지표 기반 판단이 가능해진다. 이게 붙어야 비로소 "Progressive Delivery"가 자동화된다. 안 붙으면 그냥 예쁜 단계 표시가 달린 수동 게이트일 뿐이다. 이게 다음 글로 따로 풀 후보 1순위다.
  • DB/스키마 호환성 : 구·신버전이 동시에 같은 DB를 본다. 점진 배포 도중에는 두 버전이 공존하므로, 마이그레이션을 양방향 호환(backward/forward compatible)으로 설계해야 한다. 이건 배포 도구가 아니라 애플리케이션 설계의 책임이다. 지난 ArgoCD 글에서 "BE는 수동 sync로 가져간다"고 한 이유와도 곧장 맞닿는 지점이다.
  • 세션 스티키니스 : Canary 중 같은 사용자가 요청마다 신/구를 오가면 상태가 깨질 수 있다. 세션 어피니티나 헤더 기반 라우팅을 함께 고려해야 한다.
  • Istio 가중치 라우팅 내부 동작 : trafficRouting.istio가 VirtualService의 route weight를 정확히 어떤 순서로 갱신하는지, 그 사이 순간의 트래픽 정합성은 어떻게 보장되는지를 한 단계 더 내려가 보고 싶다.

결국 Rollouts도 한 문장으로 환원된다. "신/구 ReplicaSet 2벌을 살려두고, Service selector의 해시를 조작해 트래픽을 점진 전환한다." 여기에 Blue-Green/Canary라는 두 전략과 promote/abort/retry라는 세 동작만 얹으면 도구의 대부분이 읽힌다.

지난 글에서 ArgoCD가 "무엇을 언제 배포하나"를 푼다고 했다. 이번에 Rollouts를 파고 나니, 그건 "한 워크로드를 어떻게 점진적으로 내보내나"를 푸는 도구였고, 두 도구가 만나는 지점도 또렷해졌다. ArgoCD가 Git을 단일 진실원천으로 삼아 "이 매니페스트를 적용하라"고 넘기면, 그 적용의 마지막 한 뼘, "그래서 트래픽을 어떻게 옮길까"를 Rollouts가 받는다. 큰 그림으로만 알던 배포 파이프라인을, 이제는 단계 하나하나가 어떤 리소스를 어떻게 건드리는지까지 설명할 수 있는 상태로 넘어왔다. 그거면 이번 학습의 목적은 달성한 셈이다.

'DevOps, SRE > GitOps' 카테고리의 다른 글

안다고 생각했던 ArgoCD를 원리부터 다시 확인해보기  (0) 2026.06.08
GitOps with ArgoCD  (0) 2025.05.13