1. 배경
운영 중 배포 도중 문제가 생겨 롤백한 적이 있었다.
신버전을 한 번에 전체 파드로 올리다 보니, 이상을 알아챘을 땐 이미 전체 트래픽이 신버전을 타고 있었다.
그래서 신버전을 일부 트래픽에만 먼저 노출하고 문제가 없으면 비중을 단계적으로 올리는 canary 배포를 검토하게 됐다.
목표는 두 가지였다.
- 문제가 나도 영향 범위를 canary 트래픽으로 한정하고 즉시 롤백한다.
- 단계마다(ex: 10% → 50% → 100%) 검증하면서 비중을 올린다.
환경은 이렇다. 쿠버네티스 클러스터가 버전이 다른 두 종류로 공존하고(argo-rollouts 컨트롤러 v1.2.0과 v1.6.6), 두 클러스터 모두 ingress로 Citrix(NetScaler)를 쓴다.
argo-rollouts는 이미 설치돼 있었지만 사내에선 blue-green 용도로만 쓰였고 canary 적용 사례가 없어, 직접 PoC가 필요했다.
2. 트래픽을 어디서 나눌 것인가
canary는 결국 v1과 v2에 트래픽을 어떤 비율로 나누느냐의 문제고, 어느 레벨에서 나누느냐에 따라 크게 두 방식으로 갈린다.
방식 A — Ingress 레벨 분할
ingress(트래픽 라우터)가 HTTP 요청 단위로 "X%는 v2로"를 직접 분배한다. pod 개수와 트래픽 비중이 분리(decouple)되므로, canary pod가 적어도 정확한 비율을 만들 수 있다.
- 요청 단위로 정확히 분할된다.
- 대신 stable을 풀가동한 채 canary를 얹는 구조면 pod 수가 늘어난다(replicas 100 → v1 100 + v2 100 = 200). 노드 리소스 헤드룸이 필요하다.
- 우리 환경에선 Citrix ingress가 canary를 지원하지만 신 클러스터에서만 가능하고, 구 클러스터는 미지원이다. Istio는 설치돼 있지 않다.
방식 B — Replica 비율 기반 분할 (Argo Rollouts)
하나의 Service가 stable·canary pod를 함께 물고, pod 개수 비율 ≈ 트래픽 비율로 분배한다. weight 10%면 canary pod를 전체의 10%만 띄운다(40 → v1 36 / v2 4).
- 총 pod 수가 일정하게 유지되어(v1 감소, v2 증가) 리소스가 효율적이다.
- 트래픽은 커넥션 단위 부하분산이라 근사치다. keep-alive로 커넥션이 길면 비율이 정확하지 않을 수 있다.
- granularity가 replica 수에 의존한다(40개면 2.5% 단위).
| 구분 | A. Ingress 레벨 | B. Replica 비율 |
|---|---|---|
| 분할 주체 | Ingress(트래픽 라우터) | Service(pod 개수 비율) |
| 트래픽 정밀도 | 요청 단위 정확 분할 | 커넥션 단위 근사 |
| pod 리소스 | stable 풀가동 + canary 추가 → pod 증가 | 총 pod 수 일정 → 효율적 |
| 우리 환경 제공 | Citrix(신 클러스터만), Istio(미설치) | argo-rollouts(두 클러스터 공통) |
3. Replica 방식을 택한 이유
세 가지가 결정적이었다.
첫째, 리소스다. ingress 방식은 canary 동안 pod가 최대 2배까지 늘 수 있는데, 운영 노드에 그만한 헤드룸이 없다. replica 방식은 총 pod 수를 유지하므로 추가 노드 없이 적용할 수 있다.
둘째, 두 클러스터에서 똑같이 동작해야 한다. Citrix ingress canary는 신 클러스터에서만 되므로, ingress 방식을 쓰면 클러스터 버전마다 매니페스트와 동작이 갈린다. 반면 Argo Rollouts는 workloadRef + setWeight/pause만 쓰면 v1.2.0과 v1.6.6에서 동일하게 동작한다. 담당자가 "이 서비스가 어느 버전 클러스터에 떠 있더라"를 신경 쓰지 않아도 된다.
셋째, 우리에게 필요한 건 요청 단위 정밀 분할이 아니었다. "조금씩 흘려보며 올린다"가 목적이라 커넥션 단위 근사 분할로 충분했다. (완전 균등 분배가 필요한 API-GW 같은 경우엔 Citrix lb method를 round-robin으로 바꾸는 선택지가 있지만, 이번엔 해당되지 않았다.)
4. PoC — 수동 canary
대상은 내부 API 서비스 하나. Deployment를 Rollout으로 바꾸되, 기존 Deployment는 그대로 두고 Rollout이 workloadRef로 그 Deployment를 파드 템플릿 소스로 참조하게 했다. Deployment는 replicas 0으로 두고(템플릿 소스 역할만), 실제 파드는 Rollout이 띄운다.
canary step은 이렇게 정의했다.
strategy:
canary:
steps:
- setWeight: 10
- pause: {} # ArgoCD UI에서 직접 promote할 때까지 10%로 대기
- setWeight: 50
- pause: {}
# 모든 step이 끝나면 자동으로 v2 100% 전환
weight 값은 매니페스트로 고정하고, 단계 진행은 ArgoCD UI의 Rollout Action(resume / promote-full / abort)으로 했다. CLI 플러그인을 따로 깔지 않아도 대시보드만으로 promote와 롤백이 된다.
검증을 쉽게 하려고 버전 트릭을 하나 썼다. 같은 서비스의 두 이미지로 "정상 v1 / 장애 v2"를 만들었는데, v1에는 GET /version 엔드포인트가 있어 200을 주고 v2에는 그 엔드포인트가 없어 404가 난다. canary로 흘러간 요청만 404가 되므로 "나쁜 배포" 상황을 손쉽게 재현할 수 있다.
여기까지가 사람이 단계마다 promote를 눌러 올리는 수동 canary다. 그런데 사실 한가지 더 목표했던 바는 "QA 검증이 필요 없는 배포는 사람이 붙어 있지 않아도 알아서 조금씩 올리고, 이상하면 알아서 롤백"이었다. 그게 다음의 analysis다.
5. AnalysisTemplate으로 자동 롤백 붙이기
Argo Rollouts는 step의 pause(사람이 promote) 자리에 analysis를 넣을 수 있다. 각 단계에서 자동 검증을 수행해, 통과하면 다음 weight로 진행하고 실패하면 자동으로 abort(=롤백)한다.
처음엔 잘못 설계했다
처음엔 "에러나면 롤백"을 부하 테스트 스크립트를 Job으로 돌려 판정하게 짰다(provider: job). 돌려보기도 전에 두 가지가 틀렸다는 걸 깨달았다.
- 배포마다 검증할 엔드포인트가 다른데, 그때마다 배포 매니페스트 안의 테스트 스크립트를 고쳐야 한다.
- Rollout이 봐야 할 건 특정 엔드포인트 테스트가 아니라 "이 롤아웃이 에러를 얼마나 내고 있나"라는 고정된 메트릭이다.
게다가 provider: job은 측정할 때마다 실제 Kubernetes Job(과 Pod)을 띄운다. 반면 web·prometheus 같은 조회형 provider는 별도 파드를 띄우지 않고 argo-rollouts 컨트롤러가 자기 프로세스에서 직접 쿼리한 뒤 결과를 AnalysisRun에 기록한다. 메트릭을 보고 판정하는 일에 매번 Job을 띄우는 건 불필요하게 무거웠다.
메트릭 조회로 다시 설계
앱은 이미 micrometer로 http_server_requests_seconds_count를 내보내고 있었고, 그게 사내 메트릭 저장소(Grafana Mimir 기반, Prometheus 호환 query API)에 수집되고 있었다. 그래서 analysis가 부하를 새로 만드는 대신, 그 메트릭에서 canary 파드의 에러율만 조회하게 했다.
provider는 web을 골랐다. 구 클러스터의 argo-rollouts(v1.2.0)는 prometheus provider가 커스텀 인증 헤더를 지원하지 않았는데, 메트릭 저장소는 Basic auth와 테넌트 헤더(X-Scope-OrgID)를 요구했다.
web provider는 임의 헤더와 POST body를 지원하고 두 클러스터 버전에서 모두 동작하므로, 메트릭 저장소의 query API를 직접 호출하는 방식으로 갔다.
canary 파드만 골라 보기
Citrix는 트래픽 라우팅 기능을 쓰지 않으므로, Service URL을 그냥 치면 stable·canary 응답이 섞인다. "canary만" 보려면 메트릭을 canary 파드로 필터해야 하는데, 마침 label 체계가 이걸 가능하게 해줬다.
- Argo Rollouts는 canary ReplicaSet의 파드에
rollouts-pod-template-hash라벨을 붙인다. - 메트릭 수집기가 파드의 k8s 라벨을 메트릭 라벨(
rollouts_pod_template_hash)로 복사한다. - Rollout이 그 canary 해시를 analysis 인자로 넘긴다(
podTemplateHashValue: Latest).
즉 앱이 해시를 신경 쓰는 게 아니라, 컨트롤러가 파드에 라벨을 붙이고 → 수집기가 그걸 메트릭 라벨로 올려주고 → 쿼리에서 그 해시로 canary만 거른다.
# Rollout — canary 해시를 analysis 인자로 주입하고 백그라운드로 상시 감시
strategy:
canary:
analysis:
templates:
- templateName: canary-error-rate
args:
- name: canary-hash
valueFrom:
podTemplateHashValue: Latest # canary(최신) ReplicaSet 해시
startingStep: 1
steps:
- setWeight: 10
- pause: {}
# ...
# AnalysisTemplate — canary 파드의 (4xx+5xx) 에러율이 5%를 넘으면 실패
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: canary-error-rate
spec:
args:
- name: canary-hash # Rollout에서 주입받는 파라미터(=canary 해시값)
metrics:
- name: canary-error-ratio
interval: 30s
failureLimit: 0 # 한 번이라도 임계 초과 시 즉시 실패 → abort
failureCondition: "asFloat(result) >= 0.05"
provider:
web:
url: "https://<메트릭-endpoint>/prometheus/api/v1/query"
method: POST
headers:
- { key: Authorization, value: "Basic <redacted>" }
- { key: X-Scope-OrgID, value: "<tenant>" }
- { key: Content-Type, value: "application/x-www-form-urlencoded" }
# canary 파드(rollouts_pod_template_hash)의 (4xx+5xx) / 전체 비율
body: 'query=(sum(rate(http_server_requests_seconds_count{application="myapp",rollouts_pod_template_hash="{{args.canary-hash}}",outcome=~"CLIENT_ERROR|SERVER_ERROR"}[2m])) or vector(0)) / (sum(rate(http_server_requests_seconds_count{application="myapp",rollouts_pod_template_hash="{{args.canary-hash}}"}[2m])) or vector(1))'
jsonPath: "{$.data.result[0].value[1]}"
동작은 이렇다. canary가 뜨면(weight 10%) background analysis가 30초마다 canary 파드의 에러율을 조회한다.
v2는 /version이 404이므로 curl로 /version에 요청을 흘려보내면 canary 에러율이 5%를 넘고 → AnalysisRun이 Failed가 되고 → Rollout이 자동으로 abort → canary를 0으로 축소한다.
결론적으로, rollout 배포 과정 중 이상 감지 시 자동 롤백된다.
구현하면서 정리해 둔 것 몇 가지.
result는 응답 JSON에서jsonPath로 뽑아낸 값이다.failureCondition만 지정하면 충족 시 Failed, 아니면 자동으로 Success로 본다(successCondition은 이 경우 정확히 여집합이라 둘 다 쓸 필요가 없다).- 트래픽이 0일 때 0/0(NaN)이 나오지 않도록
or vector(0)/or vector(1)로 가드를 뒀다. webprovider라서 쿼리를 URL 인코딩해 욱여넣는 대신 POST form body에 평문으로 넣었다. 매니페스트에서 쿼리 조건이 그대로 읽혀 관리가 편하다.
6. 정리
- canary는 트래픽을 ingress에서 나누느냐, replica 비율로 나누느냐로 갈린다. 리소스 효율과 "두 클러스터에서 동일 동작"이라는 조건 때문에 replica 기반(Argo Rollouts)을 택했다.
- 수동 canary는
pausestep + ArgoCD UI promote로 충분하다. - 자동 롤백은 부하 Job을 돌리는 게 아니라 "롤아웃의 고정 에러율 메트릭"을 조회하는 게 맞다. 조회형 provider는 컨트롤러가 직접 쿼리하므로 별도 워크로드도 생기지 않는다.
- canary만 정확히 보려면
rollouts-pod-template-hash라벨 필터가 핵심이고, 이 라벨은 컨트롤러가 파드에, 수집기가 메트릭에 자동으로 붙여준다.
한계도 분명하다. 트래픽 라우팅(Istio 등)이 없는 Citrix 환경이라 분할은 커넥션 단위 근사치이고, canary만 정밀하게 보려면 메트릭 라벨 체계에 의존한다. 트래픽 라우터가 있는 환경이라면 요청 단위 분할과 dynamicStableScale 같은 더 정밀한 제어까지 쓸 수 있다. 지금 환경에서 추가 설치 없이 "점진 배포 + 자동 롤백"을 갖추는 선택으로는 이 구성이 합리적이었다.
'Kubernetes' 카테고리의 다른 글
| 평균 CPU 35%에서 발생한 90초 서비스 정지 — k8s CPU Limit과 CFS Throttling 원인 분석 (0) | 2026.08.13 |
|---|---|
| HPC 환경에서 SLRUM -> K8S 마이그레이션 연구 (1) | 2025.02.14 |
| Kubernetes - Istio (0) | 2022.06.03 |
| Minikube 실습 (0) | 2022.06.03 |
| Kubernetes Quiz(2) (0) | 2022.06.03 |