쿠버네티스에 애플리케이션을 배포하다 보면 매니페스트가 순식간에 수십 개로 불어난다. Deployment, Service, ConfigMap, Ingress, HPA가 환경마다 조금씩 다르고, 이미지 태그 하나 바꾸려고 여러 파일을 손대다 보면 “지금 운영에 정확히 무엇이 배포돼 있는가”에 답하기 어려워진다. Helm은 이 매니페스트 묶음을 하나의 차트(chart)로 패키징하고, 버전을 붙여 릴리스 단위로 배포·업그레이드·롤백하게 해주는 도구다.

그런데 Helm을 손으로 helm install, helm upgrade만 반복하면 결국 kubectl apply 하던 시절의 문제를 이름만 바꿔 반복하게 된다. 진짜 가치는 차트 버전과 앱 버전을 분리해 관리하고, 릴리스를 CI에서 자동화하며, 실패 시 즉시 롤백할 수 있는 파이프라인을 갖출 때 나온다. 이 글에서는 차트 버저닝, 값 계층화, 안전한 롤아웃, 자동 롤백, CI 패키징·서명·배포까지 실전 설정과 함께 정리한다.

차트 버전과 앱 버전은 다른 것이다

Chart.yaml에는 언뜻 비슷해 보이는 두 버전 필드가 있다. version은 차트 자체의 버전이고, appVersion은 차트가 배포하는 애플리케이션의 버전이다. 이미지 태그만 1.4.2에서 1.4.3으로 올렸을 뿐 차트 구조는 그대로라면 appVersion만 올리고, 반대로 템플릿을 리팩터링하거나 리소스를 추가했다면 앱 버전과 무관하게 version을 올린다.

차트 version은 유의적 버전(SemVer)을 따라야 하며, Helm 저장소는 이 값으로 차트를 식별한다.

# Chart.yaml
apiVersion: v2
name: payments-api
description: 결제 API 서비스 차트
type: application

# 차트 구조가 바뀔 때 올린다 (SemVer 필수)
version: 2.3.1

# 배포하는 애플리케이션 버전 — 이미지 태그와 연동
appVersion: "1.4.3"

dependencies:
  - name: postgresql
    version: "13.2.x"   # 서브차트는 범위로 고정, 마이너까지만 자동 추종
    repository: "https://charts.example.internal"

실무 규칙 하나를 못 박아 두면 좋다. 운영에 나가는 차트 version은 절대 재사용하지 않는다. 같은 2.3.1을 내용만 바꿔 다시 푸시하면 저장소 캐시와 배포 이력이 어긋나 “분명 최신인데 예전 게 뜬다”는 추적 불가능한 사고로 이어진다.

values 계층화: 환경 차이를 값으로 흡수한다

차트를 재사용 가능하게 만드는 핵심은 환경별 차이를 템플릿이 아니라 값 파일로 분리하는 것이다. 기본값은 values.yaml에, 환경 오버라이드는 별도 파일에 두고 -f로 겹쳐 쌓는다(뒤 파일 우선).

# values.yaml — 안전한 기본값 (운영 기준)
replicaCount: 3
image:
  repository: registry.example.internal/payments-api
  tag: ""              # 비워두고 배포 시 주입, appVersion 을 폴백으로
  pullPolicy: IfNotPresent
resources:
  requests: { cpu: "250m", memory: "256Mi" }
  limits:   { cpu: "500m", memory: "512Mi" }
autoscaling: { enabled: true, minReplicas: 3, maxReplicas: 10 }

기본값을 운영 기준으로 두는 이유는 실수의 방향을 안전하게 만들기 위해서다. 오버라이드를 깜빡해도 최악이 넉넉한 리소스로 뜨는 쪽이지, 운영이 저사양으로 나가는 사고는 아니다.

# values-staging.yaml — 스테이징만 덮어쓴다
replicaCount: 1
autoscaling:
  enabled: false        # 스테이징은 오토스케일 끔

환경별 파일은 오직 기본값과 달라지는 항목만 담아야 한다. 전체 값을 복붙하면 기본값이 바뀌어도 반영되지 않아 두 파일이 조용히 어긋난다. 템플릿에서 이미지 태그는 {{ .Values.image.tag | default .Chart.AppVersion }}처럼 짜서, 주입값을 우선하되 없으면 appVersion으로 폴백하면 “태그를 깜빡해 latest가 나가는” 사고를 막는다.

배포 명령은 -f values.yaml -f values-staging.yaml --set image.tag=... 순으로 계층을 쌓는다. 값의 출처가 명령 한 줄에 드러나 추적하기 쉽다.

안전한 롤아웃: –atomic 과 –wait

helm upgrade의 기본 동작은 매니페스트를 적용하고 즉시 성공을 반환하는 것이다. 파드가 실제로 뜨는지는 기다리지 않는다. 그래서 이미지 풀 실패나 크래시 루프가 나도 릴리스는 “성공”으로 기록된다. 이걸 막는 조합이 --wait와 --atomic이다.

  • --wait: 모든 리소스가 Ready(파드 Ready, Deployment 최소 가용 레플리카 충족 등)가 될 때까지 대기한다.
  • --atomic: 업그레이드가 --timeout 안에 성공하지 못하면 자동으로 이전 리비전으로 롤백한다. 반쯤 배포된 상태로 방치되지 않는다.
# 실패하면 이전 리비전으로 자동 원복 — 반쪽 배포 방지
helm upgrade --install payments-api ./charts/payments-api 
  -f charts/payments-api/values-prod.yaml 
  --set image.tag="1.4.3" 
  --namespace payments 
  --atomic 
  --wait 
  --timeout 5m

--atomic이 제대로 작동하려면 파드가 준비되었는지를 정확히 판별할 수 있어야 한다. readiness 프로브가 없으면 Helm은 컨테이너가 시작되기만 하면 Ready로 간주해, 트래픽을 받을 수 없는 파드를 성공으로 오판한다. 프로브는 --atomic 신뢰성의 전제 조건이다.

# templates/deployment.yaml — 프로브가 있어야 --wait/--atomic 이 정확해진다
readinessProbe:
  httpGet: { path: /healthz/ready, port: http }
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3
livenessProbe:
  httpGet: { path: /healthz/live, port: http }

릴리스 이력과 롤백

Helm은 릴리스마다 리비전(revision)을 남긴다. 각 리비전은 그 시점의 렌더링된 매니페스트 전체를 스냅숏으로 보관하므로, 롤백은 차트를 다시 계산하는 것이 아니라 저장된 스냅숏을 그대로 되돌리는 것이다. 덕분에 롤백이 빠르고 예측 가능하다.

# 리비전 이력 확인 (STATUS: deployed / superseded / failed)
helm history payments-api -n payments

# 특정 리비전으로 롤백 (0 → 직전)
helm rollback payments-api 5 -n payments --wait --timeout 3m

주의할 점은 리비전 이력이 무한히 쌓이지 않는다는 것이다. Helm 3은 기본적으로 최근 10개만 보관하며, 이 한도는 helm upgrade --history-max 25로 조정한다. 너무 작으면 “몇 단계 전으로 롤백”이 불가능해지고, 너무 크면 릴리스 시크릿이 비대해진다. 배포 빈도가 높다면 20~30 정도가 무난하다.

업그레이드 순서를 통제하는 훅

매니페스트를 적용하는 것만으로 충분하지 않을 때가 있다. 대표적으로 데이터베이스 마이그레이션은 새 파드가 뜨기 전에 끝나 있어야 한다. Helm 훅(hook)은 릴리스 생명주기의 특정 시점에 리소스를 실행하게 해준다. Job 매니페스트에 훅 어노테이션을 붙여 pre-upgrade 시점에 마이그레이션을 돌리고, 성공해야만 롤아웃이 진행되도록 묶는다.

# templates/migration-job.yaml — 훅 어노테이션 (metadata.annotations)
"helm.sh/hook": pre-upgrade,pre-install   # 업그레이드 직전 실행
"helm.sh/hook-weight": "-5"               # 낮을수록 먼저 실행
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
# spec.backoffLimit: 0 → 마이그레이션은 재시도 없이 즉시 실패시킨다

한 가지 함정이 있다. 훅으로 만든 리소스는 --atomic 롤백의 대상이 아니다. 마이그레이션이 스키마를 이미 바꾼 뒤 앱 롤아웃이 실패해 롤백되면, 코드는 예전 버전인데 스키마는 새 버전인 불일치가 남는다. 그래서 마이그레이션은 이전 버전 코드와도 호환되도록 설계해야 한다. 컬럼을 삭제하는 대신 먼저 추가만 하고 삭제는 다음 릴리스로 미루는 확장-수축 패턴이 안전하다.

CI에서 차트 검증·패키징·배포

차트도 코드다. 릴리스 전에 린트와 렌더링 검증을 거쳐야 한다. helm lint는 구조적 오류를, helm template은 값이 주입된 매니페스트의 유효성을 검증한다. --dry-run=server를 붙이면 클러스터 어드미션 웹훅까지 사전 확인할 수 있다.

# .gitlab-ci.yml — 차트 검증 → 패키징
lint-chart:
  image: alpine/helm:3.15.0
  script:
    - helm lint charts/payments-api -f charts/payments-api/values-prod.yaml
    # 렌더 결과가 유효한 K8s 매니페스트인지 검증
    - helm template charts/payments-api -f charts/payments-api/values-prod.yaml > /dev/null

package-chart:
  image: alpine/helm:3.15.0
  script:
    - helm package charts/payments-api --version "$CI_COMMIT_TAG"
  rules:
    - if: '$CI_COMMIT_TAG =~ /^vd+.d+.d+$/'   # SemVer 태그만 릴리스

패키징한 .tgz는 OCI 레지스트리에 밀어 넣는 것이 최근 표준이다. 별도 차트 저장소 서버 없이 이미 쓰는 컨테이너 레지스트리를 그대로 재사용할 수 있다. 푸시는 helm push payments-api-2.3.1.tgz oci://registry.example.internal/charts, 소비 측은 oci://registry.example.internal/charts/payments-api --version 2.3.1을 그대로 helm upgrade --install의 차트 위치로 넘기면 된다.

공급망 신뢰: 차트 서명과 검증

저장소에서 내려받은 차트가 변조되지 않았는지 보장하려면 서명이 필요하다. Helm은 provenance 파일(.prov)로 무결성과 출처를 검증한다. 패키징 시 서명을 붙이고 설치 시 --verify로 확인하면 중간에 바꿔치기된 차트를 걸러낼 수 있다.

# 패키징과 동시에 서명 (provenance .prov 파일 생성)
helm package charts/payments-api --sign 
  --key "[email protected]" --keyring ~/.gnupg/secring.gpg

# 설치 시 서명 검증 — 무결성/출처가 어긋나면 실패
helm install payments-api payments-api-2.3.1.tgz --verify 
  --keyring ./pubring.gpg -n payments

서명 검증을 CI 배포 단계의 강제 게이트로 두면, 승인되지 않은 키로 만든 차트나 손상된 아티팩트가 운영으로 흘러드는 경로를 막을 수 있다. 앞서의 불변 버전 원칙까지 더하면 “무엇이 언제 나갔는가”가 검증 가능하게 남는다.

마무리

Helm을 제대로 쓰는 것은 helm install을 아는 것과 다른 문제다. 핵심은 차트 version과 appVersion을 분리해 불변으로 관리하고, 환경 차이는 값 파일 계층으로 흡수하며, 롤아웃은 --atomic --wait와 readiness 프로브로 안전하게 묶고, 실패 시 리비전 스냅숏으로 즉시 롤백할 수 있게 만드는 것이다. 롤백 대상이 아닌 훅 리소스는 하위 호환으로 설계해 코드-스키마 불일치를 예방한다.

다만 자동화가 만능은 아니다. --atomic은 프로브가 정확할 때만 신뢰할 수 있고, 훅으로 만든 부수 효과는 자동 롤백이 되돌려주지 않는다. 도구가 제공하는 안전장치의 전제 조건을 이해하고, 그 경계 밖의 일을 사람이 설계로 메꿀 때 비로소 릴리스 파이프라인이 실제로 안전해진다.

자주 묻는 질문

Q. –atomic 을 걸었는데도 롤백이 안 되고 실패 상태로 남는 경우가 있습니다.
A. 대개 --timeout 내에 롤백 자체가 완료되지 못한 경우입니다. 자동 롤백도 이전 리비전의 파드가 Ready 되기를 기다리므로, 이전 버전마저 뜨지 못하는 상황(리소스 부족 등)이면 롤백이 멈춥니다. 원인을 해소한 뒤 helm rollback으로 명시적 리비전을 지정해 복구합니다.

Q. 서브차트 의존성 버전은 어디까지 고정해야 하나요?
A. 운영 안정성이 중요하다면 13.2.x처럼 마이너까지 고정해 패치만 자동 추종하게 하는 것이 실용적입니다. helm dependency update로 Chart.lock을 생성하면 잠긴 정확한 버전이 기록되므로, 이 lock 파일을 커밋해 재현 가능한 빌드를 보장하세요.