Skip to Content
BlogrollbackWindow는 왜 그 안전장치를 깨는가

rollbackWindow는 왜 그 안전장치를 깨는가

#Argo Rollouts#GitOps#Istio#트러블슈팅

컨트롤러는 롤백도 새로운 Rollout으로 처리합니다.

이번에 겪은 두 문제는 이 동작에서 시작됐습니다.

첫 번째 문제는 롤백 자체가 AnalysisRun에 의해 abort되는 것입니다.

장애가 발생한 버전을 이전 이미지로 되돌리면 pod template이 바뀌고, 컨트롤러는 이를 새로운 리비전으로 인식합니다. 그러면 background AnalysisRun도 다시 시작됩니다.

하지만 롤백을 시작한 시점은 이미 장애가 진행 중입니다.

현재 Analysis 쿼리는 stable과 canary를 구분하지 않고 서비스 전체 오류율을 보기 때문에, 새 AnalysisRun 역시 높은 오류율을 그대로 읽습니다. 그 결과 장애를 복구하기 위해 시작한 롤백이 다시 abort될 수 있습니다.

이 문제를 피하기 위해 rollbackWindow를 사용했습니다.

그런데 rollbackWindow는 빠른 롤백을 위해 일반적인 step 진행을 건너뜁니다.

그 순간 1부에서 살펴본

새 ReplicaSet이 준비되지 않았다면 이전 weight를 유지한다.

는 안전장치의 의미가 달라집니다.

step index가 끝으로 이동한 상태에서 Controller가 찾는 “이전 weight”가 마지막 setWeight: 100이 되기 때문입니다.

2026-08-21 prod에서 실제로 발생한 문제는 이 두 번째 경로였습니다.

이 글에서는 그 동작 원인까지만 따라갑니다.

해결 방법은 3부에서 다룹니다.

1. 먼저 결론

전체 흐름부터 정리하면 다음과 같습니다.

  1. 일반적인 롤백은 새로운 리비전으로 처리되면서 background AnalysisRun도 다시 시작됩니다.
  2. 현재 Analysis 쿼리는 리비전을 구분하지 않기 때문에 장애 중인 서비스 전체 오류율을 읽습니다.
  3. 이 때문에 롤백 자체가 Analysis에 걸려 abort될 수 있습니다.
  4. 이를 피하기 위해 rollbackWindow.revisions: 3을 사용했습니다.
  5. rollbackWindow 안의 롤백은 step을 건너뛰고 AnalysisRun도 취소합니다.
  6. 문제는 step index가 끝으로 이동한 뒤 traffic weight를 계산하는 과정입니다.
  7. 새 ReplicaSet이 아직 목표 replica까지 준비되지 않았다면 Controller는 이전 setWeight를 역순으로 찾습니다.
  8. 마지막 step이 setWeight: 100이면 그 값이 “이전 weight”로 선택됩니다.
  9. 결과적으로 롤백 대상 ReplicaSet이 충분히 준비되기 전에 100% 트래픽을 받을 수 있습니다.

정상 배포에서는 같은 코드가 안전장치였습니다.

5% → pause → 100%

순서대로 진행한다면 100% 단계에서 파드가 준비되지 않았을 때 이전 weight는 5%이기 때문입니다.

하지만 rollbackWindow가 step을 끝까지 건너뛰면 같은 역탐색이 마지막 setWeight: 100을 집습니다.

안전장치가 사라진 것이 아니라, 안전장치가 참고하는 값이 달라진 것입니다.

2. 첫 번째 실패 — Analysis가 rollback을 취소한다

일반적인 GitOps 롤백은 이미지 태그를 이전 값으로 되돌리는 방식입니다.

pod template이 변경되면 Rollouts Controller는 이를 새로운 리비전으로 취급하고 Canary 절차를 다시 시작합니다.

사고 컴포넌트의 첫 step이 setWeight: 1이고 replica가 20대라면 단순 계산은 다음과 같습니다.

20 × 1% = 0.2 → ceil = 1 Pod → minPodsPerReplicaSet: 2 → 실제 canary = 2 Pods

두 파드가 준비되면 소량의 트래픽이 롤백 대상 버전으로 전달되고 background AnalysisRun도 동작합니다.

문제는 AnalysisRun이 보는 범위입니다.

현재 Prometheus 쿼리는 리비전을 구분하지 않고 destination_service 기준으로 서비스 전체 요청을 집계합니다.

따라서 롤백 직후 상황은 대략 다음과 같습니다.

롤백 대상 버전 1% 장애가 난 현재 버전 99% └── 서비스 전체 오류율 → AnalysisRun

롤백 대상 자체가 정상이어도 대부분의 트래픽은 아직 장애 버전으로 향하고 있습니다.

당연히 서비스 전체 오류율은 바로 내려가지 않습니다.

현재 설정은

interval: 20s failureLimit: 2

이므로 실패 측정이 누적 세 번이면 AnalysisRun이 Failed가 됩니다.

즉 가장 빠른 경우 AnalysisRun 생성 이후 약 40초 전후에 abort 조건에 도달할 수 있습니다.

AnalysisRun이 실패하면 Rollout은 abort되고, canary traffic은 0으로 되돌아갑니다.

여기서 canary는 되돌아가려던 이전 버전입니다.

결과적으로 트래픽은 다시 장애가 난 stable로 돌아갑니다.

Rollback 시작 이전 버전 일부 기동 새 AnalysisRun 시작 서비스 전체의 높은 오류율 감지 AnalysisRun Failed Rollback Abort 장애 버전으로 다시 복귀

말 그대로 롤백이 스스로를 취소하는 상태가 됩니다.

가장 직접적인 해결은 Analysis가 stable과 canary를 구분하도록 만드는 것입니다.

하지만 당시 구성에서는 Prometheus 메트릭만으로 두 리비전을 쉽게 분리할 수 없는 상태였습니다.

그래서 다른 방법을 택했습니다.

rollbackWindow입니다.

3. rollbackWindow가 정상 step 흐름을 어떻게 건너뛰는가

rollbackWindow는 최근의 ReplicaSet으로 되돌아갈 때 일반적인 Canary 단계를 다시 밟지 않고 빠르게 이전 버전을 복구하기 위한 기능입니다.

예를 들어

rollbackWindow: revisions: 3

으로 설정하면 최근 revision 범위 안의 ReplicaSet으로 돌아갈 때 fast rollback 경로를 사용할 수 있습니다.

여기서 3은 시간 단위가 아닙니다.

현재 stable과 롤백 대상 ReplicaSet 사이에 몇 개의 ReplicaSet이 존재하는지를 기준으로 window 안인지 판단합니다.

즉 Rollout의 과거 ReplicaSet이 실제로 남아 있어야 합니다.

이 때문에 rollbackWindowrevisionHistoryLimit과도 관계가 있습니다. 되돌아가려는 ReplicaSet이 이미 삭제됐다면 기존 ReplicaSet을 재사용하는 fast rollback 자체가 성립하지 않습니다.

window 안의 롤백으로 판단되면 Controller는 중요한 두 가지 동작을 합니다.

첫 번째는 현재 step index를 끝으로 이동시키는 것입니다.

개념적으로는 다음과 같습니다.

0: setWeight 1 1: pause 2: setWeight 5 3: pause 4: setWeight 100 rollbackWindow currentStepIndex → stepCount

1% → pause → 5% → pause → 100%를 다시 수행하지 않고 모든 step을 완료한 상태처럼 만듭니다.

두 번째는 AnalysisRun을 건너뛰는 것입니다.

실행 중인 AnalysisRun도 취소됩니다.

이 동작 덕분에 앞장에서 본

롤백이 새로운 AnalysisRun의 높은 오류율에 걸려 다시 abort되는 문제

는 사라집니다.

여기까지는 의도한 동작입니다.

문제는 이 step skip이 traffic weight 계산과 만나는 지점에서 발생합니다.

4. promote --fullrollbackWindow가 갈라지는 지점

rollbackWindow와 비슷하게 step을 건너뛰는 기능이 하나 더 있습니다.

promote --full입니다.

둘 다 step을 건너뛰고 Analysis를 종료할 수 있지만 traffic weight를 처리하는 방식은 다릅니다.

promote --full자신만의 traffic weight 처리 경로를 가지고 있습니다.

핵심은 새 ReplicaSet이 아직 충분히 준비되지 않았다면 traffic weight를 바로 올리지 않는다는 점입니다.

dynamicStableScale을 사용하지 않는 일반적인 구성에서는 현재 Canary weight를 그대로 유지합니다.

예를 들어 5% 단계에서 promote --full을 실행하면,

현재 traffic = 5% 새 RS: 2 → 5 → 12 → 20 Pods traffic: 5% ───────────────────────

처럼 새 ReplicaSet을 먼저 확장합니다.

즉 step은 건너뛰지만 현재 traffic weight는 동결합니다.

새 ReplicaSet이 준비되기 전에 갑자기 100%를 전달하지 않습니다.

이 보호 로직은 과거 promote --full에서도 비슷한 문제가 존재했기 때문에 추가된 것입니다.

중요한 것은 이것입니다.

step을 건너뛴다는 사실 자체가 위험한 것은 아니다.

promote --full처럼 step skip에 맞는 별도의 traffic weight 보호 경로가 있다면 안전하게 동작할 수 있습니다.

문제는 rollbackWindow에는 같은 경로가 없다는 점입니다.

5. rollbackWindow에는 같은 보호 경로가 없다

traffic weight를 결정하는 로직에는 여러 조건이 순서대로 존재합니다.

개념적으로 단순화하면 다음과 같습니다.

Abort인가? ↓ 아니오 Canary Pod가 0대인가? ↓ 아니오 PromoteFull인가? ↓ 아니오 일반적인 step weight 계산

promote --full은 자신의 분기에서 처리됩니다.

하지만 rollbackWindow만을 위한 별도의 weight 분기는 없습니다.

따라서 step index는 이미 끝으로 이동했지만 traffic weight 계산은 일반적인 step 경로로 들어갑니다.

가중치 결정 진입reconcileTrafficRouting:187 stable 복귀동적 복귀 중:194 완전 승격IsFullyPromoted:199 abortIsAborted():217 canary 0대Available 0:229 PromoteFullstatus 필드:243 그 밖index != nil가중치 재계산stable 가용량 기준가중치 0라우팅 정리 · 승격 시 한번에가중치 0stable 100%가중치 0canary 로 안 보냄가중치 동결RS 만 전량 확장:245 역탐색미도달 & !PromoteFull마지막 setWeight우리 형태면 100
트래픽 가중치를 정하는 if/else 체인. 여섯 갈래가 위에서 아래 순서로 평가되고 먼저 걸린 하나가 이깁니다. abort(:199)가 역탐색이 있는 마지막 갈래(:243)보다 앞이므로, abort 상태에서는 역탐색 사다리가 아예 실행되지 않습니다 — 이 사고는 abort 되지 않은 rollbackWindow 경로에서만 성립합니다. promote --full 은 :229 에 자기 갈래가 있어 현재 가중치를 동결하지만, rollbackWindow 는 자기 갈래가 없어 마지막 갈래로 떨어집니다 — rollout/trafficrouting.go:187-263

일반적인 step 경로에는 1부에서 본 안전장치가 있습니다.

새 ReplicaSet이 현재 step이 요구하는 상태까지 준비되지 않았다면 이전 setWeight를 사용한다.

정상 배포에서는 안전합니다.

예를 들어

0: setWeight 5 1: pause 2: setWeight 100

에서 currentStepIndex = 2이고 새 ReplicaSet이 아직 20대까지 준비되지 않았다면 역순으로 이전 setWeight를 찾습니다.

index 2 index 1 = pause index 0 = setWeight 5

따라서 실제 traffic은 5%에 머뭅니다.

그런데 rollbackWindow에서는 index가 step의 끝으로 이동합니다.

사고 컴포넌트의 step은 다음과 같았습니다.

0: setWeight 1 1: pause 2: setWeight 5 3: pause 4: setWeight 100 currentStepIndex = 5

새 ReplicaSet은 아직 충분히 준비되지 않았습니다.

그러면 같은 역탐색이 시작됩니다.

index 5 index 4 = setWeight 100

첫 번째로 만나는 값이 바로 100입니다.

정상 배포에서는

"이전 weight를 유지한다" = 더 낮은 traffic을 유지한다

였습니다.

하지만 rollbackWindow가 step을 모두 건너뛴 뒤에는

"이전 weight를 유지한다" = 마지막 setWeight: 100을 사용한다

가 됩니다.

같은 안전장치가 반대 결과를 만드는 이유입니다.

6. 왜 롤백 대상 ReplicaSet은 아직 2대뿐인가

롤백에서 되돌아가려는 과거 ReplicaSet은 Controller 관점에서는 다시 newRS가 됩니다.

사고 당시 replica는 20대였고 첫 step은 setWeight: 1이었습니다.

따라서 처음 필요한 canary replica는

ceil(20 × 1%) = 1

이지만

minPodsPerReplicaSet: 2

가 적용되면서 실제로는 2대가 먼저 준비됩니다.

이후 rollbackWindow가 step index를 끝으로 보내면 ReplicaSet의 최종 목표는 20대로 올라갑니다.

즉 Controller가 원하는 최종 상태 자체는 정상입니다.

desired ReplicaSet = 20

문제는 20대가 실제로 준비되는 데 시간이 필요하다는 것입니다.

그 사이 traffic weight 계산은 별도의 경로에서 진행됩니다.

RS 크기 경로 — 파드 수를 정한다가중치 경로 — 트래픽 비율을 정한다index = stepCountcurrentStep = nilL75 RS 크기reconcileCanaryReplicaSetsGetCurrentSetWeightnil 이면MaxTrafficWeight목표 20대하한은 이미 넘어 무효L57 가중치reconcileTrafficRouting:245 역탐색i := index−1; i >= 0;i−−100%Ready 2대가 받는다게이트stable 만 검사 · canary미검증
index == stepCount 일 때 ReplicaSet 크기와 트래픽 가중치는 서로 다른 함수가 정합니다. 크기 쪽은 currentStep == nil 을 보고 100을 쓰고, 가중치 쪽은 스텝 목록을 거꾸로 훑습니다. 갈라진 두 값이 검사는 한 곳으로 모입니다 — stable 만 보는 게이트입니다. 도달까지 걸리는 시간이 다르다는 것은 4절의 애니메이션이 보입니다.

이 둘의 시점이 어긋납니다.

현재 Available: 2 최종 Desired: 20 Traffic Weight: 100%

ReplicaSet은 20대로 확장되기 시작하지만, 그보다 먼저 100% traffic이 적용될 수 있습니다.

즉 문제는 Controller가 2대만 띄우려고 했던 것이 아닙니다.

최종 목표는 20대가 맞지만, 실제 20대가 준비되기 전에 traffic이 먼저 100%로 바뀐 것입니다.

가용성 검사가 막아 주지 못하는 이유

traffic weight를 적용하기 전에는 ReplicaSet 가용성을 확인하는 guard도 존재합니다.

하지만 이 검사가 보는 것은 stable 쪽입니다.

Canary traffic이 100%라면 stable traffic은 0%입니다.

canary desired weight = 100% stable desired weight = 0%

따라서 stable에 필요한 replica 역시 0대가 되고 검사는 자연스럽게 통과합니다.

문제의 순간에는

Canary: Available = 2 Traffic = 100% Stable: Required = 0

상태가 됩니다.

즉 이 마지막 guard는 100% traffic을 받을 canary가 실제로 충분한지 확인하는 장치가 아닙니다.

정상적인 step 진행에서는 앞단의 “이전 weight 유지” 로직이 그 역할을 사실상 대신했습니다.

하지만 rollbackWindow에서 그 이전 weight가 100으로 바뀌면서 이 전제가 깨졌습니다.

7. 실측 — 2026-08-21

실제 사고 컴포넌트의 조건은 다음과 같았습니다.

runtime replicas: 20 minPodsPerReplicaSet: 2 rollbackWindow.revisions: 3 steps: 1% → pause 5m → 5% → pause 5m → 100%

롤백 이후의 흐름은 다음과 같았습니다.

시각Ready / 20RS 목표가중치상태
13:58:56020%롤백 sync, 이전 RS가 다시 기동
수 초 후020%step index가 끝으로 이동, Analysis 취소
13:59:45220100%마지막 setWeight: 100 선택, RS 확장 시작
13:59:50~14:00:252 → 0 → 2 → 020100%소수 Pod에 전체 traffic 집중
14:00:30~14:01:002020100%20/20 준비 후 정상화

확인된 영향은 다음과 같습니다.

항목
healthy endpoint 0 구간30초
2대에 집중된 부하96 rps
기대치 대비 요청 결손약 3,058건
확인된 UH 50328건

여기서 가장 중요한 구간은

Available Pods = 2 Traffic = 100%

가 동시에 존재했던 시점입니다.

2대가 전체 요청을 받으면서 readiness가 흔들렸고,

2 → 0 → 2 → 0

형태로 healthy endpoint가 진동했습니다.

즉 사고를 시작한 원인은 readiness probe가 아닙니다.

준비되지 않은 ReplicaSet으로 traffic 100%가 먼저 넘어간 것이 시작점입니다.

probe 설정은 이미 과부하된 소수 Pod를 빠르게 endpoint에서 제거하면서 피해 양상을 더 크게 만들었습니다.

마지막 setWeight: 100이 사고 경로를 열었고, probe 동작은 그 이후의 영향을 증폭시켰습니다.

8. 기전 정리

1부에서 정상 Canary를 안전하게 만드는 세 가지 조건을 살펴봤습니다.

롤백에서는 다음과 같이 바뀝니다.

정상 CanaryrollbackWindow 롤백
step을 순서대로 진행step index를 끝으로 이동
준비가 안 되면 이전 weight 유지이전 weight가 마지막 setWeight: 100이 됨
AnalysisRun으로 이상 감지AnalysisRun을 취소해 롤백 abort를 방지

Analysis를 취소하는 동작 자체는 잘못된 것이 아닙니다.

그 동작이 없으면 첫 번째 문제처럼 장애 중인 서비스 메트릭을 새 AnalysisRun이 다시 읽고 롤백 자체를 abort할 수 있습니다.

문제는 Analysis를 건너뛰기 위해 사용한 fast rollback 경로가 step도 함께 건너뛴다는 것입니다.

그리고 traffic weight 계산에는 promote --full과 달리 rollbackWindow 전용 보호 경로가 없습니다.

그 결과 정상 배포에서 안전장치였던

준비가 안 됐으면 이전 weight를 사용한다.

준비가 안 됐으면 마지막 setWeight: 100을 사용한다.

로 바뀝니다.

2026-08-21 사고의 핵심은 이 한 문장으로 정리할 수 있습니다.

ReplicaSet의 최종 목표는 20대였지만, 20대가 준비되기 전에 마지막 setWeight: 100이 traffic에 먼저 적용됐다.

그렇다면 해결 방법도 이 경로에서 Controller가 역탐색해 가져갈 값을 바꾸는 쪽에서 찾을 수 있습니다.

다음 편에서는 마지막 setWeight: 100을 제거했을 때 정상 배포와 롤백이 각각 어떻게 달라지는지, 그리고 다른 대안들이 왜 같은 문제를 해결하지 못하는지 살펴봅니다.

03 마지막 setWeight: 100을 왜 지워야 하는가

01 정상 Canary는 왜 안전한가: step과 AnalysisRun