# 검증을 통과해야 승격되는 배포 만들기 - blue/green 게이트의 설계 지점

새 버전을 올리기 전에 실제 환경에서 검증하고 싶습니다. 스테이징에서 통과한 것과 운영 데이터로 통과하는 것은 다르기 때문입니다. 그래서 새 버전을 옆에 띄워 두고, 자동 검증을 돌리고, 통과하면 트래픽을 넘기는 구조를 만들게 됩니다.

개념은 명확한데 실제로 만들면 결정할 것이 여럿입니다. 검증 실패 시 무엇이 남는가, 두 벌을 동시에 띄울 여력이 있는가, 승격을 누가 기록하는가. 이 글에서는 컨트롤러 기반 방식이 무엇을 자동으로 해 주는지, 그것이 안 맞는 경우에 무엇을 직접 만들어야 하는지 정리합니다.

# 1. 컨트롤러가 대신해 주는 것

Argo Rollouts 같은 컨트롤러를 쓰면 blue/green이 필드 몇 개로 표현됩니다.

  • activeService: "specifies the service to update with the new template hash at time of promotion"
  • previewService: 승격 전 새 버전으로 트래픽을 보내 확인하는 통로
  • autoPromotionEnabled: "make the rollout automatically promote the new ReplicaSet to the active service once the new ReplicaSet is healthy"
  • scaleDownDelaySeconds: "used to delay scaling down the old ReplicaSet after the active Service is switched"

Service의 셀렉터를 바꾸는 것이 곧 승격입니다. 두 ReplicaSet이 동시에 떠 있고, 어느 쪽으로 트래픽이 가는지는 Service가 정합니다. 전환이 원자적이고 되돌리기도 셀렉터를 되돌리면 됩니다.

검증은 분석으로 붙입니다.

Pre-Promotion Analysis: Runs before traffic switches to the new version. A failed analysis blocks the promotion entirely, aborting the rollout.

Post-Promotion Analysis: Runs after traffic switches. If it fails, the rollout enters an aborted state and traffic reverts to the previous stable ReplicaSet.

strategy:
  blueGreen:
    activeService: myapp
    previewService: myapp-preview
    autoPromotionEnabled: false        # 검증 통과 후에만 승격
    scaleDownDelaySeconds: 600         # 롤백 여지를 남긴다
    prePromotionAnalysis:
      templates:
        - templateName: smoke-and-load
      args:
        - name: service
          value: myapp-preview

분석 자체도 조건으로 표현합니다.

metrics:
  - name: error-rate
    interval: 30s
    count: 10
    successCondition: result < 0.01
    failureLimit: 2
    provider:
      prometheus:
        query: |
          sum(rate(http_requests_total{service="{{args.service}}",code=~"5.."}[1m]))
          / sum(rate(http_requests_total{service="{{args.service}}"}[1m]))

failureLimit이 있는 이유가 있습니다. 측정 한 번의 실패로 승격을 막으면 잡음에 흔들립니다. 몇 번까지 실패를 허용할지가 곧 게이트의 민감도입니다.

# 2. 그래서 무엇이 안 맞는가

컨트롤러 방식의 전제는 두 벌을 동시에 띄울 수 있다는 것입니다. previewService로 새 버전에 트래픽을 보내려면 그 시점에 두 ReplicaSet이 모두 살아 있어야 합니다.

일반적인 무상태 서비스에서는 잠깐의 두 배가 감당됩니다. 감당 안 되는 경우가 있습니다.

전용 하드웨어를 쓰는 워크로드. GPU를 쓰는 추론 서버라면 두 벌은 GPU 두 배입니다. 잠깐이라도 확보할 수 없으면 새 ReplicaSet이 Pending에 머물고, 컨트롤러는 그 상태로 멈춥니다. GPU가 왜 쪼개 쓸 수 없는 자원인지는 GPU는 쪼개 쓸 수 없다 (opens new window)에 정리했습니다.

시작이 매우 느린 워크로드. 모델 가중치를 로딩하는 데 수 분이 걸리면 scaleDownDelaySeconds 동안 두 벌이 유지되는 비용이 커집니다.

검증이 몇 분 이상 걸리는 경우. 정확도 평가나 부하 시험은 30초짜리 지표 질의가 아닙니다. 그 시간 동안 두 벌을 유지해야 합니다.

이런 경우 선택지는 두 벌을 상시 유지하지 않고, 검증 기간에만 임시로 띄우는 구조입니다. 즉 "고정된 live + 필요할 때만 만드는 candidate"입니다.

컨트롤러 blue/green live 고정 + 임시 candidate
자원 전환 중 2배 검증 중에만 2배
전환 방식 Service 셀렉터 매니페스트 교체
롤백 셀렉터 되돌리기 (즉시) 이전 버전 재배포
검증 시간 짧을수록 유리 길어도 됨
승격 기록 컨트롤러 상태 Git 커밋
구현 비용 낮음 높음

마지막 두 줄이 트레이드오프의 핵심입니다. 직접 만들면 승격이 Git 이력으로 남습니다. 언제 무엇이 왜 승격됐는지가 커밋과 PR에 기록되고, 되돌리기도 Git 되돌리기가 됩니다. 대신 컨트롤러가 공짜로 주던 것들을 전부 만들어야 합니다.

# 3. 직접 만들 때 반드시 정해야 하는 것들

승격 파이프라인을 만들 때 빠뜨리기 쉬운 결정들입니다.

(1) candidate가 live와 같으면 무엇을 할 것인가. 새 이미지가 없는데 파이프라인이 돌면 같은 버전으로 candidate를 만들고 검증하고 승격합니다. 아무 변화도 없는 배포가 발생하고, 매번 자원과 시간을 씁니다. 비교해서 같으면 사이클 전체를 건너뛰어야 합니다.

# 승격 대상과 현재 live가 같으면 종료
if [ "$CANDIDATE_DIGEST" = "$LIVE_DIGEST" ]; then
  echo "candidate == live, 사이클 생략"
  exit 0
fi

(2) 검증 순서를 무엇으로 할 것인가. 정확도 검증과 부하 검증이 둘 다 있다면 순서가 중요합니다. 싸고 빨리 실패하는 것을 먼저 돌립니다. 정확도가 틀린 모델에 부하를 거는 것은 낭비입니다.

(3) 게이트를 건너뛰는 경로를 만들 것인가. 긴급 배포를 위해 필요하지만, 만들어 두면 그 경로가 기본이 됩니다. 만든다면 건너뛴 사실이 알림에 드러나야 합니다. 통과와 건너뜀이 같은 문구로 보고되면 게이트가 있으나 마나입니다.

(4) 실패한 candidate를 언제 정리할 것인가. 검증에 실패한 리소스를 남겨 두면 자원을 계속 점유합니다. 지우면 실패 원인을 조사할 수 없습니다. 일정 시간 유지 후 자동 정리가 절충이고, 그 정리를 별도 워크플로로 분리하면 수동 조사 시간을 벌 수 있습니다.

(5) 승격을 직접 푸시할 것인가 PR로 할 것인가. 자동 파이프라인이 보호된 브랜치에 직접 푸시하려면 우회 권한이 필요합니다. PR로 만들면 기록이 남고 리뷰 여지가 생기지만 자동 병합 설정이 또 필요합니다. 검증이 충분히 신뢰할 만하면 자동 병합, 아니면 사람 승인이 기준입니다.

# 4. GitOps와 맞물리는 지점

승격을 Git 커밋으로 표현하면 GitOps 도구와의 경계가 새로 문제가 됩니다.

prune을 켤 것인가. candidate 리소스를 만들고 지우는 구조라면, Git에서 사라진 리소스를 GitOps 도구가 지워 주어야 합니다. 즉 prune이 켜져 있어야 합니다. 그런데 prune을 켜는 것은 위험한 변경입니다. Git에 없는 것을 전부 지우므로, 손으로 만들어 둔 리소스나 이름이 어긋난 리소스가 함께 사라집니다.

순서가 정해집니다.

  1. 현재 무엇이 prune 대상인지 먼저 조사한다 (--dry-run --prune)
  2. 지워져도 되는 것만 남을 때까지 정리한다
  3. 그 다음에 prune을 켠다
  4. 그 다음에 승격 파이프라인을 붙인다

prune을 켜는 것과 파이프라인을 붙이는 것을 같은 변경으로 하면 안 됩니다. 문제가 생겼을 때 어느 쪽 때문인지 구분할 수 없습니다.

CRD처럼 지워지면 안 되는 리소스가 있다면 보호 애너테이션이 필요합니다. 이 부류의 문제는 CRD를 GitOps로 관리할 때의 경계 (opens new window)에 정리했습니다.

컨트롤러가 쓰는 필드와 충돌합니다. 승격 과정에서 GitOps 도구가 관리하는 리소스를 다른 컨트롤러도 갱신하면 동기화가 계속 어긋납니다. 서버 사이드 어플라이의 필드 소유권이나 무시 규칙으로 다뤄야 하고, 그 방법은 ArgoCD ignoreDifferences가 동작하지 않는 세 가지 이유 (opens new window)에 정리했습니다.

# 5. 알림이 게이트를 무력화하는 방식

게이트를 만들고 나면 알림을 붙이게 되는데, 여기서 게이트의 가치가 소리 없이 사라질 수 있습니다.

모든 결과를 같은 문구로 보내면 사람이 구분하지 않습니다. "승격 완료"가 검증 통과와 게이트 건너뜀 양쪽에서 나가면, 게이트가 꺼져 있어도 아무도 모릅니다. 경로마다 문구를 갈라야 합니다.

아무 일도 안 한 실행까지 알리면 채널이 무의미해집니다. (1)의 "candidate == live" 경우처럼 사이클을 건너뛴 실행은 알림 대상이 아닙니다.

성공을 실패로 잘못 보고하는 경우도 있습니다. 조건에 따라 워크플로 단계를 건너뛰었을 때 그것을 실패로 판정하면 거짓 경보가 됩니다. 조건 판정의 기준을 "단계가 돌았는가"가 아니라 **"의도한 결과가 나왔는가"**로 두어야 합니다.

알림 자체의 설계 원칙은 ArgoCD Notifications의 네 조각 (opens new window)에 정리했는데, 게이트에 붙이는 알림에는 규칙이 하나 더 붙습니다. 게이트를 통과했는지, 건너뛰었는지, 실패했는지가 문구만 보고 구분되어야 합니다.

# 6. 직접 확인하는 방법

컨트롤러를 쓴다면 상태와 분석 결과를 봅니다.

# 롤아웃 상태 - Paused면 승격 대기 중
kubectl argo rollouts get rollout myapp --watch

# 분석 실행 결과와 실패한 측정
kubectl get analysisrun -l rollouts-pod-template-hash=<hash> \
  -o custom-columns='NAME:.metadata.name,PHASE:.status.phase'
kubectl describe analysisrun <name> | grep -A10 'Metric Results'

# Service 셀렉터가 어느 쪽을 가리키는지 - 승격 여부의 진짜 근거
kubectl get svc myapp myapp-preview \
  -o jsonpath='{range .items[*]}{.metadata.name}{"  →  "}{.spec.selector}{"\n"}{end}'

직접 만든 파이프라인이라면 승격 이력이 Git에 있으므로 거기서 봅니다.

# 무엇이 언제 승격됐는가
git log --oneline --follow -- path/to/live-manifest.yaml | head -20

# 현재 live가 가리키는 다이제스트와 실제 실행 중인 것이 같은가
grep -o 'sha256:[0-9a-f]*' path/to/live-manifest.yaml
kubectl get pods -l app=myapp \
  -o jsonpath='{range .items[*]}{.status.containerStatuses[0].imageID}{"\n"}{end}' | sort -u

두 값이 다르면 Git과 클러스터가 어긋난 것이고, 동기화가 멈췄거나 승격 커밋이 아직 반영되지 않은 상태입니다.

게이트가 실제로 막고 있는지도 시험해 봐야 합니다.

# 일부러 실패하는 후보로 파이프라인을 돌려 승격이 막히는지 확인
# - 이 시험을 안 하면 게이트가 있다고 믿는 상태와 동작하는 상태를 구분할 수 없다

게이트는 통과하는 경우만 시험하면 검증되지 않습니다. 실패 경로를 정기적으로 확인해야 합니다.

# 7. 트러블슈팅

증상 원인 조치
새 버전이 Pending에서 멈춤 두 벌 분량의 자원 부족 임시 candidate 방식 검토
검증은 통과하는데 승격이 안 됨 autoPromotionEnabled: false 수동 승격 또는 설정 변경
롤백했는데 옛 버전이 없음 scaleDownDelaySeconds 경과 값 상향, 롤백 창 확보
잡음에 게이트가 자주 실패 failureLimit이 너무 엄격 측정 횟수·허용 실패 조정
변화 없는 배포가 반복 candidate와 live 비교 없음 같으면 사이클 생략
candidate 리소스가 쌓임 정리 경로 없음 정리 워크플로 분리
게이트가 꺼졌는데 아무도 모름 알림 문구가 동일 경로별 문구 분리
거짓 실패 알림 건너뛴 단계를 실패로 판정 결과 기준으로 조건 작성
승격 후 리소스가 사라짐 prune과 파이프라인을 동시 도입 변경을 분리해서 적용

# 8. 마무리

  • 컨트롤러 기반 blue/green은 Service 셀렉터 교체를 승격으로 삼습니다. 전환과 롤백이 원자적이라는 것이 가장 큰 이점입니다.
  • 전제는 두 벌을 동시에 띄울 수 있다는 것입니다. 전용 하드웨어를 쓰거나 검증이 오래 걸리면 이 전제가 깨집니다.
  • 그 경우 "live 고정 + 임시 candidate" 구조가 대안이고, 대가로 컨트롤러가 주던 것을 직접 만들어야 합니다. 얻는 것은 승격이 Git 이력으로 남는다는 점입니다.
  • 직접 만들 때 빠뜨리기 쉬운 결정이 다섯입니다. 변화 없는 사이클 생략, 검증 순서, 게이트 우회 경로, 실패 candidate 정리, 승격을 푸시로 할지 PR로 할지.
  • prune 활성화와 파이프라인 도입은 같은 변경으로 하지 않습니다. 문제가 생겼을 때 원인을 가를 수 없습니다.
  • 게이트는 통과 경로만 시험하면 검증되지 않습니다. 일부러 실패시키는 시험경로별로 구분되는 알림 문구가 게이트를 살아 있게 만듭니다.

# 참고