라이브러리 차트 하나 바꿨는데 왜 11개 서비스가 동시에 흔들렸나?

결론부터 쓰면, 원인은 두 가지가 겹친 복합 장애였다. 하나는 Chart.lock을 커밋하지 않아 파이프라인마다 서로 다른 하위 차트 버전을 받은 것, 다른 하나는 --atomic 없이 배포해서 실패가 났을 때 자동으로 멈추지 않은 것이다. 둘 중 하나만 있었어도 영향 범위는 훨씬 작았을 것으로 본다.

이 글의 핵심

  • 공통(라이브러리) 차트 변경은 서비스 수만큼 영향이 곱으로 커진다. 변경 전 helm template 결과를 반드시 비교해야 한다.
  • Chart.lock을 Git에 커밋하지 않으면 로컬·CI·배포 환경이 서로 다른 하위 차트 버전을 설치할 수 있다출처: Rackspace Spot.
  • --atomic --wait --timeout을 쓰지 않으면 실패한 업그레이드가 Not Ready 상태로 방치될 수 있다.
  • --atomic은 Kubernetes 리소스 롤백일 뿐, DB 마이그레이션 같은 외부 상태는 복구하지 못한다.
  • 릴리스 이력은 Kubernetes Secret으로 쌓이므로 --history-max 값을 서비스별로 정해야 한다.

2026년 1월 기준 우리 팀은 11개 서비스가 공통 common-app 라이브러리 차트(0.x대 내부 버전, 비공개 레지스트리 배포)를 참조하고 있었다. probe 설정과 PodDisruptionBudget 기본값을 한 곳에서 관리하기 위해 도입한 구조였는데, 이번 변경은 readinessProbe.initialDelaySeconds를 10에서 5로 낮추는, 겉보기엔 사소한 수정이었다. 배포 직후 11개 서비스 중 7개에서 파드가 Ready로 전환되지 못하고 CrashLoopBackOff 직전까지 갔고, 엣지 게이트웨이 기준 504 에러율이 평소 0.1% 미만에서 약 23분간 4.8%까지 올라갔다.

정말 같은 버전의 하위 차트가 설치됐나?

아니었다. 첫 가설은 "probe 지연시간 5초가 일부 서비스의 부팅 시간보다 짧다"는 것이었지만, 로그를 보니 더 이상한 점이 있었다. 같은 라이브러리 차트를 참조하는데도 어떤 서비스는 신규 probe 설정이 적용됐고, 어떤 서비스는 이전 설정 그대로였다. helm get values로 각 릴리스의 실제 하위 차트 버전을 확인하니 0.4.1을 받은 서비스와 0.4.2를 받은 서비스가 섞여 있었다.

원인은 Chart.lock을 .gitignore에 넣어둔 레거시 설정이었다. CI 파이프라인이 매 배포마다 helm dependency update를 새로 실행했는데, 이 시점에 레지스트리에 어떤 버전이 올라와 있느냐에 따라 받는 결과가 달랐다. 마침 라이브러리 차트를 0.4.1에서 0.4.2로 올리는 배포와 서비스별 배포가 같은 날 겹치면서, 먼저 돌아간 파이프라인과 나중에 돌아간 파이프라인이 서로 다른 버전을 받은 것이다. Chart.lock을 커밋해 고정하지 않으면 로컬 개발 환경과 배포 환경의 하위 차트 버전이 달라질 수 있다는 점은 Helm 운영 가이드에서도 지적하는 부분이다출처: Rackspace Spot.

배포는 왜 자동으로 안 멈췄나?

멈추도록 설정돼 있지 않았기 때문이다. 우리가 쓰던 배포 명령은 helm upgrade --install app ./chart -f values-prod.yaml 수준이었고, --atomic도 --wait도 --timeout도 없었다. 그 결과 readiness가 실패한 파드가 있어도 Helm은 "설치 명령 자체는 성공"으로 보고했고, 실제 서비스 상태를 알아챈 건 모니터링 알람이 울린 뒤였다.

만약 helm upgrade --install --atomic --wait --timeout 5m 형태로 배포했다면, 지정한 시간 안에 리소스가 준비되지 않을 때 자동으로 이전 리비전으로 롤백하도록 구성할 수 있었다출처: ctoc helm skill. 다만 이 5분이라는 값이 모든 서비스에 맞는 정답은 아니라는 점도 같이 확인했다. 우리 서비스 중 하나는 초기화 캐시 워밍에 4분 가까이 걸리는데, 똑같이 5분으로 걸어두면 정상 배포도 타임아웃으로 롤백될 수 있어 서비스별로 시간을 따로 재서 넣어야 했다.

장애를 다시 만들어 원인을 확정한 방법은?

사후 조치 전에 같은 조건을 재현해 확인했다. 스테이징 클러스터에서 Chart.lock을 지운 상태로 두 파이프라인을 10초 간격으로 실행해 하위 차트 버전 0.4.1과 0.4.2가 실제로 섞여 설치되는지 확인했고, helm template로 두 릴리스의 렌더링 결과를 diff 떠서 probe 설정 값이 다르게 나오는 걸 텍스트로 확인했다. 이어서 --atomic 없이 동일한 변경을 배포해 readiness 실패 시 Helm이 "성공"으로 종료하는 것도 재현했다. 반대로 --atomic --wait --timeout 90s를 걸고 같은 실패를 재현하자 약 95초 뒤 자동으로 이전 리비전으로 롤백이 시작되는 것을 로그로 확인했다. 이 세 가지 재현 결과를 근거로 원인을 "버전 드리프트 + 롤백 미설정"의 복합 요인으로 확정했다.

무엇을 바꿨고 어떤 지표로 확인했나?

세 가지를 바꿨다. 첫째, Chart.lock을 모든 저장소에서 Git 추적 대상으로 전환하고, CI에서 helm dependency build(update가 아니라)로 고정된 버전만 받도록 바꿨다. 둘째, 모든 서비스 배포 명령에 --atomic --wait --timeout을 붙이되 timeout 값은 서비스별 초기화 시간을 실측해 2~6분 범위로 다르게 설정했다. 셋째, --history-max를 기본 무제한에서 10으로 제한해 릴리스 Secret이 무한정 쌓이지 않도록 했다출처: ctoc helm skill.

확인 지표는 두 가지로 봤다. 하나는 라이브러리 차트 변경 시 helm template diff를 CI 단계에 강제해 변경 범위를 배포 전에 눈으로 보게 한 것, 다른 하나는 이후 세 차례 라이브러리 차트 업그레이드에서 수동 롤백 개입 없이 자동 복구가 끝난 횟수다. 세 차례 모두 자동 롤백이 2분 이내에 완료됐고, 수동 개입은 없었다. 다만 이 수치는 우리 환경에서의 관측치이며, 다른 팀의 timeout·복구 시간을 보장하는 값은 아니다.

한 가지 더 짚을 점은, --atomic이 커버하지 못하는 영역이다. 이번 11개 서비스 중 하나는 post-install 훅으로 DB 스키마 변경을 실행하는 구조였는데, 만약 그 배포에서 실패가 났다면 Helm 롤백은 Kubernetes 리소스만 되돌릴 뿐 이미 실행된 SQL까지 되돌리지 못한다. 다행히 이번 장애에는 스키마 변경이 끼어있지 않았지만, 이후로는 스키마 변경을 반드시 하위 호환 단계로 분리하고 애플리케이션 배포와 별도의 복구 절차를 문서화해 두기로 했다.

같은 함정을 피하려면 무엇을 기본값으로 삼나?

기본값을 세 가지로 정리했다. 라이브러리 차트를 쓴다면 Chart.lock은 예외 없이 커밋한다. 모든 프로덕션 배포는 --atomic --wait --timeout을 기본으로 걸되 timeout은 서비스마다 실측해서 넣는다. 그리고 --atomic을 "모든 것을 되돌리는 안전장치"로 오해하지 않고, DB·외부 API 변경은 별도 복구 절차로 분리한다. 장기적으로는 OCI 레지스트리에서 태그 대신 다이제스트로 차트를 고정하는 방식도 검토 중인데출처: Helm 공식 블로그, 이는 아직 적용 전이라 효과를 수치로 보여줄 단계는 아니다.

핵심 정리

  • 공통 라이브러리 차트 변경은 영향 범위가 서비스 수만큼 커지므로, 배포 전 helm template 결과를 diff로 비교하는 절차가 필요하다.
  • Chart.lock을 Git에서 추적하지 않으면 환경마다 다른 하위 차트 버전이 설치될 수 있다출처: Rackspace Spot.
  • --atomic --wait --timeout은 실패한 업그레이드를 자동 롤백시키지만, timeout 값은 서비스별 초기화 시간에 맞춰 따로 측정해야 한다출처: ctoc helm skill.
  • --atomic은 Kubernetes 리소스 범위의 롤백이며 DB 마이그레이션 등 외부 상태 변경은 별도 복구 절차가 필요하다.
  • 릴리스 이력은 Kubernetes Secret으로 쌓이므로 --history-max를 서비스 요구 기간에 맞춰 제한하는 편이 좋다.