안다고 생각했던 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다. maxSurge와 maxUnavailable로 "한 번에 몇 개씩 교체할지"는 조절되지만, 본질은 하나다. 한 번 시작하면 100%까지 자동으로 굴러간다.
여기서 못 하는 게 네 가지다.
- 점진 트래픽 분배 : "신버전에 우선 5%만 흘려보고 싶다"가 안 된다. 파드 비율이 곧 트래픽 비율로 강제되고(Service가 라운드로빈), 중간에 멈춰서 일부만 노출한 채 관찰하는 게 불가능하다.
- 관찰 후 결정(게이트) : 배포 도중 에러율이나 지연을 보고 "여기서 멈춰, 더 갈지 정하자"가 없다. probe가 통과하면 그냥 다음으로 넘어간다.
- 빠른 롤백 : 문제를 발견해도 구버전 ReplicaSet은 이미 scale-down 중이라 되돌리는 데 시간이 걸린다.
- 지표 기반 자동 판단 : Prometheus 지표를 보고 자동으로 중단하거나 승급하는 훅이 없다.
Argo Rollouts는 Deployment와 거의 같은 spec을 갖는 Rollout이라는 CRD를 제공하고, 전용 컨트롤러가 이 리소스를 감시한다. 채우는 빈칸은 정확히 위 네 가지다.

spec이 거의 동일하다는 건 실무에서 중요하다. 기존 Deployment를 Rollout으로 바꾸는 비용이 낮다는 뜻이기 때문이다. 다른 점은 spec.strategy에 blueGreen 또는 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가 가리키는 Deployment의 replicas는 반드시 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%) 올리며, 중간중간 멈춰서 관찰한다.

steps는 setWeight(가중치 %)와 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
차이가 선명하다. stableService와 canaryService 두 개를 두고, trafficRouting.istio로 VirtualService의 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가 곧 장애는 아니다
운영의 핵심은 Rollout의 status.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라는 라벨 하나가 곧 장애를 뜻하지 않는다. "신버전 전개가 멈췄다"와 "서비스가 죽었다"는 다른 얘기다.
사고는 도구가 아니라 사람이 만든다
원리를 잡고 나니, 실전에서 사고가 나는 지점은 대부분 도구의 버그가 아니라 운영의 빈틈이었다.
autoPromotionEnabled: false+ promote 망각 → Rollout이 영원히 Paused. "배포했는데 신버전이 안 떠요"의 1순위 원인이다. 의도된 수동 게이트인지, 그냥 까먹은 건지를 먼저 구분해야 한다.pause: {}(duration 없음) → 무기한 정지. CI 파이프라인이 이 지점에서 멈춰 기다리도록 설계됐는지 확인해야 한다.- abort 후 방치 → stable로 복귀해 서비스는 멀쩡하지만 신버전은 영영 안 올라간다.
retry나 새 배포가 필요하다.Degraded를 장애로 오인해 놀라기는 쉽지만, 정작 진짜 문제는 "아무도 retry를 안 눌렀다"는 쪽인 경우가 많다. trafficRouting없이setWeight신뢰 → §4.3 그대로. replica가 적으면 가중치가 사실상 무의미하다.scaleDownDelaySeconds미설정 → Blue-Green 스위치 직후 구버전이 빠르게 사라져 롤백 여지가 좁아진다.- 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 |