infra
Platform

모듈 맵

[Kubernetes] 나만의 커스텀 Helm Chart 작성법과 환경별 Value 튜닝

0 / 29 완료

펼치기
0 / 29 완료0%

쿠버네티스 & GitOps · 22 / 29

[Kubernetes] 나만의 커스텀 Helm Chart 작성법과 환경별 Value 튜닝

templates/, Chart.yaml, values.yaml 구조부터 Go 템플릿 함수와 서브차트까지 프로덕션 수준의 Helm Chart를 직접 만들어봅니다

🚨INCIDENT ALERT
HIGH

팀마다 비슷한 Deployment YAML을 복사해 쓰다가 포트, 라벨, probe 설정이 제각각이 되었습니다. 공통 패턴을 Chart로 만들면 환경별 값만 바꾸고 검증된 템플릿을 재사용할 수 있습니다. Helm Chart 작성은 플랫폼팀이 배포 표준을 코드로 제공하는 방식입니다.

Helm Chart 직접 작성하기

팀에 새 마이크로서비스가 추가될 때마다 누군가 밤새 YAML을 복붙하며 수정하고 있습니다. 환경마다 이미지 태그가 달라야 하고, 스테이징과 프로덕션의 레플리카 수가 다르고, 시크릿 이름도 제각각입니다. 처음 한 번만 고생하면 그 이후에는 helm install my-service ./charts/my-service -f values-prod.yaml 한 줄로 끝나는 구조를 만들 수 있습니다. Helm Chart를 직접 작성하면 인프라 변경이 코드 리뷰를 거치고, 히스토리가 남고, 롤백이 가능해집니다. 이 모듈에서는 빈 디렉토리에서 시작해 프로덕션에서 실제로 사용하는 수준의 Chart를 직접 만들어봅니다.


이번 챕터에서 배울 것

직접 작성한 Helm Chart로 Kubernetes 배포를 코드로 관리하는 방법을 익힙니다. Go 템플릿 함수부터 서브차트 의존성 관리까지, 팀 전체가 사용하는 공용 Chart를 만들 수 있게 됩니다.

  • 1Helm Chart 디렉토리 구조(Chart.yaml, values.yaml, templates/)의 역할을 설명할 수 있다
  • 2Go 템플릿의 변수 참조, 조건문, 반복문 기본 문법을 사용할 수 있다
  • 3include, toYaml, required, default, nindent 등 핵심 함수를 활용할 수 있다
  • 4_helpers.tpl로 named template 재사용 패턴을 구현할 수 있다
  • 5서브차트(dependency)로 외부 Chart 의존성을 관리할 수 있다
  • 6helm template과 helm lint으로 로컬에서 디버깅할 수 있다
실습 환경 준비

Helm v3와 kubectl이 설치되어 있고 클러스터에 연결되어 있어야 합니다. minikube나 kind 등 로컬 클러스터로도 충분합니다.

Helm 버전 확인 (v3 이상 필요)
helm version --short
로컬 Kubernetes 클러스터 연결 확인
kubectl cluster-info
실습 네임스페이스 생성
kubectl create namespace helm-dev
실습 디렉토리 생성
mkdir -p ~/helm-workshop && cd ~/helm-workshop
실습 완료 후 정리

helm uninstall my-webapp -n helm-dev && kubectl delete namespace helm-dev

💡개념

Helm Chart 디렉토리 구조

팀마다 비슷한 Deployment YAML을 각자 관리하다 보면 probe 설정이 누락되거나 리소스 제한이 제각각이 되는 문제가 생깁니다. 직접 Chart를 작성하면 이 공통 패턴을 한 곳에서 관리하고 환경별 값만 values.yaml로 분리할 수 있습니다. helm create가 자동 생성하는 구조를 이해하지 않고 쓰면 불필요한 파일이 많아 혼란스럽습니다. Chart.yaml, values.yaml, templates/ 세 요소의 역할 분담을 먼저 파악하면 어떤 로직이 어디에 있어야 하는지 판단이 생겨 유지보수 가능한 Chart를 만들 수 있습니다.

Helm Chart 디렉토리 구조 — Chart.yaml(메타데이터)·values.yaml(기본 설정값)·templates/(Go 템플릿 매니페스트, _helpers.tpl 공용 함수)·charts/(의존 차트). templates의 변수를 values로 채워 렌더링하므로, 공통 패턴은 템플릿에 두고 환경별 차이만 values로 분리해 probe·리소스 제한 누락을 방지확대

Chart 기본 구조

my-webapp/ 구조입니다.

  • Chart.yaml — Chart 메타데이터 (필수)
  • values.yaml — 기본값 정의 (필수)
  • charts/ — 서브차트(dependency) 저장 디렉토리
  • templates/ — Kubernetes manifest 템플릿
    • _helpers.tpl — 재사용 named template (렌더링 대상 아님)
    • deployment.yaml, service.yaml, ingress.yaml, configmap.yaml
    • NOTES.txt — helm install 후 출력될 안내 메시지

Chart.yaml — Chart 메타데이터

YAML
# Chart.yaml
apiVersion: v2          # Helm v3는 반드시 v2
name: my-webapp
description: A production-ready web application chart
type: application       # application | library
version: 0.1.0          # Chart 버전 (SemVer)
appVersion: "1.0.0"     # 실제 앱 버전 (참조용, 이미지 태그와는 별도)

# 외부 Chart 의존성 (서브차트)
dependencies:
  - name: redis
    version: "18.x.x"
    repository: "https://charts.bitnami.com/bitnami"
    condition: redis.enabled   # values.yaml의 값으로 선택적 설치

values.yaml — 기본값 정의

Helm values 오버라이드 우선순위와 릴리스 리비전 — chart values.yaml(기본) < -f my-values.yaml(파일) < --set key=value(CLI 최우선) 순으로 병합돼 최종 values로 템플릿을 렌더한다. 매 install/upgrade는 리비전으로 저장돼(REV 1→2→3) helm rollback으로 이전 리비전 상태로 즉시 복구할 수 있으며 롤백도 새 리비전을 만든다. --set은 기록이 안 남으니 운영은 -f values 파일을 git으로 관리한다확대

YAML
# values.yaml
replicaCount: 1

image:
  repository: nginx
  tag: "1.25"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: false
  host: ""
  annotations: {}

resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

env: []
#  - name: DATABASE_URL
#    valueFrom:
#      secretKeyRef:
#        name: db-secret
#        key: url

redis:
  enabled: false      # 서브차트 활성화 여부
  auth:
    enabled: false

templates/deployment.yaml — 핵심 템플릿

YAML
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-webapp.fullname" . }}
  labels:
    {{- include "my-webapp.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-webapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-webapp.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.targetPort }}
          {{- if .Values.env }}
          env:
            {{- toYaml .Values.env | nindent 12 }}
          {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

💡개념

helm install을 치면 매니페스트가 나오기까지 — 렌더링 파이프라인 5단계

helm install이나 helm template 한 줄이면 최종 K8s 매니페스트가 나오지만, 그 사이에 차트 로드·값 병합·Go 템플릿 실행·검증이 순서대로 일어납니다. 앞에서 본 세 요소(Chart.yaml·values.yaml·templates/)가 이 파이프라인의 어느 단계에서 합쳐지는지 알면, "값이 왜 안 먹지", "왜 YAML이 깨지지", "함수가 왜 not defined지" 같은 오류를 특정 단계 문제로 좁힐 수 있습니다. 이것은 완성된 차트를 쓰는 법(helm-basics)이 아니라, 내가 만든 차트가 어떻게 렌더되는지의 관점입니다.

TEXT
helm install my-app ./chart -f prod.yaml --set image.tag=v2
   │
   ① 차트 로드 — Chart.yaml · values.yaml · templates/ · charts/(서브차트) 읽기
   │
   ② 값 병합 — values.yaml(기본) < -f prod.yaml(파일) < --set(CLI) 순으로 덮어씀
   │      → 최종 .Values 객체 확정
   │
   ③ 템플릿 실행 — templates/의 {{ ... }} 를 .Values로 치환
   │      (include·toYaml·required·default 함수, if 조건, range 반복)
   │
   ④ YAML 산출 — 렌더된 텍스트를 K8s 매니페스트로 파싱
   │
   ⑤ 검증·전송 — lint/스키마 확인 후 API server로 apply
   │      (helm template은 ④까지만 하고 여기서 멈춰 텍스트만 출력)
   ▼
[결과]  최종 Deployment · Service · ConfigMap 매니페스트

각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:

단계하는 일여기서 막히면
① 차트 로드Chart.yaml·values.yaml·templates/charts/의 서브차트를 읽어들임서브차트 미다운로드(helm dependency update 안 함) → charts/가 비어 의존성 누락
② 값 병합values.yaml-f 파일--set 순으로 우선순위대로 병합해 최종 .Values 확정키 경로 오타(.Values.replicaCount 철자)·--set 미반영 → 값이 기본값으로 남음 (helm get values로 확인)
③ 템플릿 실행templates/의 템플릿 표현식을 .Values로 치환하고 함수·if·range를 평가named template 이름 불일치 → function not defined / 인자 누락 → wrong number of args
④ YAML 산출치환된 텍스트를 실제 YAML로 파싱nindent/toYaml 들여쓰기 안 맞으면 error converting YAML·did not find expected key
⑤ 검증·전송helm lint·스키마로 확인 후 매니페스트를 API server로 전송required 값 누락 → 렌더 중단(명확한 메시지) / 클러스터 인증·리소스 충돌은 이 단계에서 실패

즉 오류가 났을 때 "몇 단계에서 깨졌나"를 먼저 정하는 것이 요령입니다 — 값이 안 먹으면 ②(helm get values로 실제 병합 결과 확인), YAML이 깨지면 ③④(helm template --debug로 렌더된 텍스트를 직접 보기), 클러스터가 거부하면 ⑤(인증·충돌)입니다. helm template은 ①~④만 하고 클러스터로 전송하지 않으므로, 배포 전에 렌더링 문제를 클러스터 없이 통째로 잡아내는 것이 정석입니다.


💡개념

Go 템플릿 핵심 함수 — include, toYaml, required, nindent

Helm 템플릿을 처음 작성할 때 가장 많이 만나는 오류가 들여쓰기 불일치나 "wrong type for value" 입니다. YAML은 들여쓰기가 문법의 일부이기 때문에, 함수 호출 결과를 올바르게 삽입하지 않으면 유효하지 않은 YAML이 만들어집니다. nindent로 들여쓰기를 맞추고, toYaml로 구조체를 직렬화하며, required로 필수 값 누락을 배포 전에 잡는 패턴은 실무 Chart에서 반복적으로 나타납니다. 이 4가지 함수의 동작을 이해하면 다른 사람의 Chart를 읽고 디버깅하는 속도도 빨라집니다.

Go 템플릿 핵심 함수 — include, toYaml, required, nindent확대

include — named template 호출 (pipeline 지원)

YAML
# template 함수는 pipeline에 연결할 수 없어서 indent가 안 됩니다.
# include를 사용하면 결과를 pipeline으로 넘길 수 있습니다.

# 잘못된 방법 (indent 불가)
{{template "my-webapp.labels" .}}

# 올바른 방법 (nindent로 들여쓰기 적용)
{{- include "my-webapp.labels" . | nindent 4 }}

# 예: 여러 manifest에서 동일한 label 블록 재사용
metadata:
  labels:
    {{- include "my-webapp.labels" . | nindent 4 }}
spec:
  selector:
    matchLabels:
      {{- include "my-webapp.selectorLabels" . | nindent 6 }}

toYaml — 값 객체를 YAML로 직렬화

YAML
# values.yaml에 정의한 구조체를 그대로 YAML로 출력
resources:
  {{- toYaml .Values.resources | nindent 2 }}

# 결과:
# resources:
#   requests:
#     cpu: 100m
#     memory: 128Mi
#   limits:
#     cpu: 500m
#     memory: 512Mi

# env 배열 처리 (아이템이 있을 때만 출력)
{{- if .Values.env }}
env:
  {{- toYaml .Values.env | nindent 2 }}
{{- end }}

# annotations 맵 처리
{{- with .Values.ingress.annotations }}
annotations:
  {{- toYaml . | nindent 4 }}
{{- end }}

required — 필수 값 검증

YAML
# 값이 비어 있으면 오류 메시지와 함께 렌더링 중단
image:
  repository: {{ required "image.repository는 필수 값입니다" .Values.image.repository }}
  tag: {{ required "image.tag는 필수 값입니다" .Values.image.tag | quote }}

# Ingress host 검증
{{- if .Values.ingress.enabled }}
  {{- $host := required "ingress.enabled가 true이면 ingress.host를 지정해야 합니다" .Values.ingress.host }}
  rules:
    - host: {{ $host }}
{{- end }}

default — 기본값 설정

YAML
# .Values에 값이 없거나 비어 있을 때 기본값 사용
replicas: {{ .Values.replicaCount | default 1 }}
pullPolicy: {{ .Values.image.pullPolicy | default "IfNotPresent" }}

# 조건부 기본값 (string 빈 값 처리)
{{- $name := .Values.nameOverride | default .Chart.Name }}
name: {{ $name | trunc 63 | trimSuffix "-" }}

nindent vs indent

YAML
# indent: 지정한 수만큼 공백 추가 (앞에 줄바꿈 없음)
# nindent: newline + indent (앞에 줄바꿈 포함) ← 주로 이것을 사용

# indent 사용 시 (줄바꿈을 직접 관리해야 함)
labels:
{{ include "my-webapp.labels" . | indent 4 }}

# nindent 사용 시 (더 깔끔함, - 로 앞 공백 제거)
labels:
  {{- include "my-webapp.labels" . | nindent 2 }}

💡개념

_helpers.tpl — named template 재사용 패턴

여러 template 파일에서 동일한 label 블록을 반복 작성하면 유지보수가 어렵습니다. _helpers.tpl에 named template을 정의하고 include로 불러 쓰면 한 곳만 수정해도 모든 manifest에 반영됩니다.

_helpers.tpl 전체 예시

YAML
{{/*
  Expand the name of the chart.
*/}}
{{- define "my-webapp.name" -}}
{{- .Values.nameOverride | default .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
  Create a default fully qualified app name.
  Release 이름이 Chart 이름을 포함하면 중복 제거
*/}}
{{- define "my-webapp.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := .Values.nameOverride | default .Chart.Name }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
  공통 label — 모든 리소스에 붙는 표준 label
*/}}
{{- define "my-webapp.labels" -}}
helm.sh/chart: {{ include "my-webapp.chart" . }}
{{ include "my-webapp.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
{{- end }}

{{/*
  Selector label — Deployment의 selector.matchLabels에 사용
  (한 번 배포 후 변경하면 안 됨!)
*/}}
{{- define "my-webapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-webapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
  Chart 이름+버전 문자열
*/}}
{{- define "my-webapp.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

named template에서 컨텍스트 전달

YAML
# . (현재 컨텍스트 전체)를 전달하는 것이 일반적
{{- include "my-webapp.labels" . | nindent 4 }}

# 특정 값만 전달할 때 (dict 사용)
{{- include "my-webapp.serviceAccountName" (dict "Values" .Values "Release" .Release) }}

# range 블록 내부에서 루트 컨텍스트 전달 ($ 사용)
{{- range .Values.ingress.hosts }}
- host: {{ .host }}
  paths:
  {{- range .paths }}
    - path: {{ .path }}
      # range 블록 내부에서 .Values는 현재 스코프(path 객체)를 가리킴
      # $.Values로 루트 컨텍스트의 Values에 접근
      pathType: {{ $.Values.ingress.pathType | default "Prefix" }}
  {{- end }}
{{- end }}

💡개념

서브차트(Dependency)로 외부 Chart 관리

my-webapp이 Redis를 필요로 한다면, Redis Chart를 직접 관리하는 대신 Helm의 dependency 메커니즘으로 가져올 수 있습니다. 서브차트는 선택적으로 활성화/비활성화할 수 있어 로컬 개발(Redis 불필요)과 프로덕션(Redis 필요) 환경을 동일한 Chart로 처리할 수 있습니다.

서브차트 설정 흐름

로컬 터미널
# 1단계: Chart.yaml에 dependency 추가 (이미 앞에서 작성함)
# dependencies:
#   - name: redis
#     version: "18.x.x"
#     repository: "https://charts.bitnami.com/bitnami"
#     condition: redis.enabled

# 2단계: repository 추가 및 dependency 다운로드
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm dependency update ./my-webapp

# 결과: charts/ 디렉토리에 redis-18.x.x.tgz 생성
ls ./my-webapp/charts/
# redis-18.6.1.tgz

# 3단계: 서브차트 values 오버라이드 (values.yaml에서)
# 서브차트 이름(redis)을 키로 사용하여 values 전달

서브차트 values 오버라이드

YAML
# values.yaml — 서브차트 설정은 서브차트 이름 아래에 작성
redis:
  enabled: true               # condition 값 (이 키로 서브차트 활성화 제어)
  auth:
    enabled: true
    password: ""  # 값은 Secret 참조 또는 별도 보호된 values 파일로 주입
  master:
    persistence:
      size: 5Gi
  replica:
    replicaCount: 1

서브차트 내부 Service 이름 참조

YAML
# templates/deployment.yaml — Redis 서비스에 연결하는 환경변수
env:
  {{- if .Values.redis.enabled }}
  - name: REDIS_HOST
    # 서브차트 Service 이름은 "<release-name>-redis-master" 형식
    value: "{{ .Release.Name }}-redis-master"
  - name: REDIS_PORT
    value: "6379"
  {{- end }}

Chart 설치 및 확인

로컬 터미널
# Redis 포함하여 설치
helm install my-webapp ./my-webapp \
  --namespace helm-dev \
  --set redis.enabled=true \
  --set-file redis.auth.password=/run/secrets/redis-password

# 렌더링 결과 확인 (클러스터 불필요)
helm template my-webapp ./my-webapp \
  --set redis.enabled=true \
  --namespace helm-dev | less

# Redis 없이 설치 (로컬 개발)
helm install my-webapp-dev ./my-webapp \
  --namespace helm-dev \
  --set redis.enabled=false

실습 — Helm Chart 처음부터 만들기

1단계: Chart 골격 생성

로컬 터미널
mkdir -p ~/helm-workshop/my-webapp/{templates,charts}
cd ~/helm-workshop/my-webapp

# Chart.yaml 작성
cat > Chart.yaml << 'EOF'
apiVersion: v2
name: my-webapp
description: Production-ready web application Helm Chart
type: application
version: 0.1.0
appVersion: "1.0.0"
EOF

2단계: values.yaml 작성

로컬 터미널
cat > values.yaml << 'EOF'
replicaCount: 2

image:
  repository: nginx
  tag: "1.25-alpine"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 80
  targetPort: 80

ingress:
  enabled: false
  host: ""

resources:
  requests:
    cpu: 100m
    memory: 64Mi
  limits:
    cpu: 200m
    memory: 128Mi

env: []
EOF

3단계: _helpers.tpl 작성

로컬 터미널
cat > templates/_helpers.tpl << 'EOF'
{{- define "my-webapp.name" -}}
{{- .Values.nameOverride | default .Chart.Name | trunc 63 | trimSuffix "-" }}
{{- end }}

{{- define "my-webapp.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := .Values.nameOverride | default .Chart.Name }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{- define "my-webapp.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
{{ include "my-webapp.selectorLabels" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "my-webapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-webapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
EOF

4단계: Deployment, Service 템플릿 작성

로컬 터미널
cat > templates/deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "my-webapp.fullname" . }}
  labels:
    {{- include "my-webapp.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "my-webapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "my-webapp.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ required "image.tag는 필수입니다" .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
          {{- if .Values.env }}
          env:
            {{- toYaml .Values.env | nindent 12 }}
          {{- end }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
EOF

cat > templates/service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
  name: {{ include "my-webapp.fullname" . }}
  labels:
    {{- include "my-webapp.labels" . | nindent 4 }}
spec:
  type: {{ .Values.service.type }}
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.targetPort }}
      protocol: TCP
  selector:
    {{- include "my-webapp.selectorLabels" . | nindent 4 }}
EOF

5단계: lint와 template으로 검증

로컬 터미널
# Chart 문법 검증
helm lint ./my-webapp
# ==> Linting ./my-webapp
# [INFO] Chart.yaml: icon is recommended
# 1 chart(s) linted, 0 chart(s) failed

# 로컬 렌더링 (클러스터 불필요)
helm template test-release ./my-webapp --namespace helm-dev

# 예상 출력:
# ---
# # Source: my-webapp/templates/service.yaml
# apiVersion: v1
# kind: Service
# metadata:
#   name: test-release-my-webapp
#   labels:
#     helm.sh/chart: my-webapp-0.1.0
#     app.kubernetes.io/name: my-webapp
#     app.kubernetes.io/instance: test-release
#     app.kubernetes.io/managed-by: Helm
# ...

# values 오버라이드 확인
helm template test-release ./my-webapp \
  --set replicaCount=3 \
  --set image.repository=myapp \
  --set image.tag=v2.0.0 \
  --namespace helm-dev

6단계: 클러스터에 설치

로컬 터미널
# 설치 (--dry-run으로 먼저 확인)
helm install my-webapp ./my-webapp \
  --namespace helm-dev \
  --dry-run

# 실제 설치
helm install my-webapp ./my-webapp --namespace helm-dev

# 결과 확인
helm list -n helm-dev
# NAME       NAMESPACE  REVISION  STATUS    CHART
# my-webapp  helm-dev   1         deployed  my-webapp-0.1.0

kubectl get all -n helm-dev
# NAME                                        READY   STATUS    RESTARTS
# pod/my-webapp-my-webapp-7d9f6b8d4c-xk2p9   1/1     Running   0
# pod/my-webapp-my-webapp-7d9f6b8d4c-r5mn2   1/1     Running   0
#
# NAME                          TYPE        CLUSTER-IP      PORT(S)
# service/my-webapp-my-webapp   ClusterIP   10.96.173.42    80/TCP

# values 변경 후 업그레이드
helm upgrade my-webapp ./my-webapp \
  --namespace helm-dev \
  --set replicaCount=3

# 롤백
helm rollback my-webapp 1 --namespace helm-dev
🔍실행 후 확인할 것
  • helm template <release> <chart> 출력을 먼저 확인 — 렌더링 결과가 예상한 YAML과 다르면 values.yaml 경로 또는 if 조건 블록 오류. 클러스터 없이 로컬에서 빠르게 검증 가능
  • helm lint 기준: 0 chart(s) linted, 0 chart(s) failed이면 문법 정상. Error 메시지가 나오면 해당 줄의 들여쓰기와 Go 템플릿 문법({{ / }}) 확인
  • helm upgrade 후 파드가 이전 버전 이미지로 동작하면 → values.yaml의 image.tag가 Chart default로 덮어씌워진 것. --set image.tag=<version>으로 명시적 오버라이드 또는 helm get values <release>로 실제 적용된 값 확인
실습 단계
1

Chart 골격 생성 및 구조 확인

mkdir -p ~/helm-workshop/my-webapp/{templates,charts} cd ~/helm-workshop/my-webapp ls -R .

예상 출력

.:
Chart.yaml  charts  templates  values.yaml

./charts:

./templates:
2

helm lint으로 Chart 문법 검증

helm lint ~/helm-workshop/my-webapp

예상 출력

==> Linting ./my-webapp
[INFO] Chart.yaml: icon is recommended
1 chart(s) linted, 0 chart(s) failed
3

helm template으로 로컬 렌더링 확인

helm template test-release ~/helm-workshop/my-webapp --namespace helm-dev

예상 출력

---
# Source: my-webapp/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: test-release-my-webapp
4

values 오버라이드 적용 후 렌더링 확인

helm template test-release ~/helm-workshop/my-webapp \ --set replicaCount=3 \ --set image.tag=v2.0.0 \ --namespace helm-dev | grep -E 'replicas|image:'

예상 출력

  replicas: 3
          image: "nginx:v2.0.0"
5

클러스터에 설치 및 상태 확인

helm install my-webapp ~/helm-workshop/my-webapp --namespace helm-dev helm list -n helm-dev kubectl get all -n helm-dev

예상 출력

NAME       NAMESPACE  REVISION  STATUS    CHART
my-webapp  helm-dev   1         deployed  my-webapp-0.1.0

문제 상황

로컬 터미널
$ helm template test ./my-webapp
Error: template: my-webapp/templates/deployment.yaml:12:7: \
executing "my-webapp/templates/deployment.yaml" at <include "my-webapp.labels" .>: \
error calling include: template: my-webapp/templates/_helpers.tpl:8:3: \
executing "my-webapp.labels" at <include "my-webapp.selectorLabels" .>: \
wrong type for value; expected string; got template

원인 분석

_helpers.tpl에서 named template을 정의할 때 define 블록 끝에 불필요한 공백이나 줄바꿈이 포함되면 이 오류가 납니다. Helm template은 공백에 매우 민감합니다.

YAML
# 잘못된 _helpers.tpl (줄바꿈이 반환값에 포함됨)
{{- define "my-webapp.selectorLabels" }}
app.kubernetes.io/name: {{ include "my-webapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{ end }}
# ^ end 앞 줄바꿈, define 뒤 줄바꿈이 문제

# 올바른 _helpers.tpl (- 로 공백 제거)
{{- define "my-webapp.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-webapp.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
# ^ define과 end 양쪽에 - 붙여 앞뒤 공백 제거

디버깅 방법

로컬 터미널
# 1단계: 어느 파일의 몇 번째 줄인지 오류 메시지에서 확인
# "my-webapp/templates/deployment.yaml:12" → 12번째 줄
# "my-webapp/templates/_helpers.tpl:8" → _helpers.tpl 8번째 줄

# 2단계: 문제 template을 단독으로 렌더링 시도
helm template test ./my-webapp --show-only templates/deployment.yaml

# 3단계: --debug 플래그로 렌더링 전 전처리 결과 확인
helm template test ./my-webapp --debug 2>&1 | head -50

# 4단계: 공백 문제 확인 — define/end 양쪽에 반드시 - 붙이기
# {{- define "name" -}} ... {{- end }}
#      ^              ^         ^
#      앞 공백 제거    뒤 공백 제거  앞 공백 제거

자주 나오는 template 오류 패턴

로컬 터미널
# 오류 1: undefined template
# Error: template: ...at <include "my-webapp.fullname" .>: function "my-webapp.fullname" not defined
# → _helpers.tpl 파일이 없거나 define 이름이 일치하지 않음

# 오류 2: wrong number of args
# Error: ...wrong number of args for include: want 2 got 1
# → include에 두 번째 인수(컨텍스트) 누락
# 잘못됨: {{ include "my-webapp.labels" }}
# 올바름: {{ include "my-webapp.labels" . }}

# 오류 3: can't evaluate field X in type interface {}
# Error: ...can't evaluate field replicaCount in type interface {}
# → .Values 접근 경로 오타
# values.yaml: replicaCount → template: .Values.replicaCount (대소문자 확인)

심화 — Chart가 렌더된 다음: 릴리스 상태와 업그레이드의 진실

💡개념

심화: Helm은 컨트롤러가 아니다 — 상태는 Secret에, 업그레이드는 3-way 병합

helm template이 잘 렌더된다고 배포가 끝난 게 아닙니다. Chart가 매니페스트를 만들어내는 것과, 그 매니페스트가 클러스터에서 어떻게 반영·추적되는지는 다른 이야기입니다. 이 차이를 모르면 "분명 배포했는데 왜 이 값이 안 바뀌지", "왜 업그레이드가 통째로 막혔지" 같은 장애 앞에서 손을 놓게 됩니다.

  • Helm은 명령형(imperative) 도구입니다: helm install·helm upgrade는 실행되는 그 순간에만 동작합니다. Deployment 컨트롤러처럼 상태를 계속 지켜보며 reconcile 하지 않습니다. 그래서 배포 후 누군가 kubectl edit로 바꾼 값은 다음 upgrade 전까지 그대로 살아 있습니다.
  • 릴리스 상태는 네임스페이스의 Secret에 저장됩니다: 각 릴리스는 리비전마다 sh.helm.release.v1.<name>.v<N> 형태의 Secret 하나로 기록됩니다. helm history·helm status는 이 Secret을 읽어 보여 줍니다. 상태가 클러스터 밖 어딘가가 아니라 바로 그 네임스페이스 안에 있다는 뜻입니다.
  • 업그레이드는 3-way 병합입니다: helm upgrade는 (1)직전에 Helm이 적용한 매니페스트, (2)이번에 렌더된 매니페스트, (3)클러스터의 현재 live 상태 셋을 비교해 패치를 만듭니다. 그래서 out-of-band로 바꾼 필드라도 Helm이 관리하는 필드면 values 기준으로 되돌아가고, Helm이 손대지 않는 필드는 살아남습니다.
  • 한 릴리스엔 한 번에 한 작업만: 업그레이드가 완료(deployed)나 롤백으로 끝나지 못하고 끊기면 릴리스가 pending-upgrade로 남아 이후 모든 작업을 막습니다.

정리하면 Chart 문법이 맞는 것과 릴리스가 건강한 것은 별개입니다. Helm을 쓸 땐 helm history로 릴리스의 상태 기계(state machine)를 늘 함께 봐야 합니다.

상황: CI 파이프라인에서 helm upgrade --install --atomic --timeout 2m가 한 번 타임아웃으로 죽은 뒤부터, 이후 모든 배포가 즉시 실패합니다. helm list를 보면 릴리스가 deployed가 아니라 pending-upgrade 상태로 멈춰 있습니다.

원인: Helm은 한 릴리스에 대해 동시에 하나의 작업만 허용합니다. 업그레이드가 정상 완료나 롤백으로 마무리되지 못하고 중간에 끊기면(타임아웃, 멈춘 hook, 프로세스 강제 종료), 릴리스 레코드가 pending-upgrade 상태로 남습니다. 이 멈춘 상태가 다음 upgrade의 진입을 막는 것입니다. --atomic이 롤백을 시도했더라도 그 롤백마저 --timeout 안에 끝나지 못하면 상태가 어정쩡하게 남을 수 있습니다.

진단: helm history <릴리스> -n <ns>로 마지막 리비전의 status가 pending-upgrade인지 확인합니다. helm status <릴리스>로 현재 상태를, kubectl get secret -n <ns> -l owner=helm,name=<릴리스>로 리비전별 릴리스 Secret 목록을 봅니다. 여기서 어느 리비전이 마지막으로 deployed였는지가 드러납니다.

해결: 마지막으로 성공한 deployed 리비전으로 helm rollback <릴리스> <리비전> -n <ns> 하는 것이 정석입니다. 롤백조차 막히면 그 pending-upgrade 리비전의 릴리스 Secret(sh.helm.release.v1.<릴리스>.v<N>)을 삭제해 멈춘 레코드를 치운 뒤 다시 upgrade 합니다. 근본 원인은 대개 워크로드 롤아웃 시간보다 짧게 잡은 --timeout이나 완료되지 않는 hook이므로, timeout을 실제 롤아웃 시간에 맞추고 hook에 적절한 삭제 정책(helm.sh/hook-delete-policy)을 걸어 재발을 막습니다.


💼
실무 맥락
현업 패턴

배경

스타트업 C사의 DevOps 엔지니어. 현재 10개 마이크로서비스가 각자 deployment.yaml, service.yaml을 별도로 관리하고 있습니다. 환경마다 수작업으로 이미지 태그를 바꾸고, 레플리카 수를 수정하다가 실수가 반복됩니다. Chart를 도입하여 표준화하는 작업을 맡았습니다.

전환 전략

1단계: 가장 단순한 서비스 하나를 Chart로 전환 (파일럿)
2단계: 공통 패턴 추출 → 팀 공용 _helpers.tpl 정립
3단계: 나머지 서비스를 순차적으로 전환
4단계: CI/CD 파이프라인에 helm upgrade 통합

기존 YAML에서 Chart로 변환하는 패턴

로컬 터미널
# 기존 deployment.yaml의 하드코딩 값을 values.yaml로 추출
# Before:
#   replicas: 2
#   image: gcr.io/myproject/user-service:abc123def
# After values.yaml:
#   replicaCount: 2
#   image:
#     repository: gcr.io/myproject/user-service
#     tag: abc123def

CI/CD 통합 예시 (GitHub Actions)

YAML
# .github/workflows/deploy.yml
- name: Deploy to Kubernetes
  run: |
    helm upgrade --install ${{ env.SERVICE_NAME }} ./charts/${{ env.SERVICE_NAME }} \
      --namespace ${{ env.NAMESPACE }} \
      --set image.tag=${{ github.sha }} \
      --set replicaCount=${{ env.REPLICA_COUNT }} \
      --atomic \
      --timeout 5m
    # --atomic: 실패 시 자동 롤백
    # --timeout: 배포 완료 대기 시간

현장에서 배운 교훈

1. selectorLabels는 한 번 배포 후 절대 변경하지 말 것
   → 변경 시 Deployment 삭제 후 재생성 필요 (다운타임 발생)

2. Chart 버전(Chart.yaml의 version)과 앱 버전(appVersion)을 분리할 것
   → Chart 구조 변경은 version 올리기, 앱 코드 변경은 appVersion 올리기

3. 시크릿은 values.yaml에 직접 쓰지 말 것
   → helm install ... --set secret.password=$(cat /tmp/secret)
   → 또는 외부 시크릿 관리 도구(External Secrets Operator) 사용

핵심 요약

개념설명
Chart.yamlChart 이름, 버전, dependency 정의
values.yaml기본값 정의 — 환경별 오버라이드 가능
templates/Kubernetes manifest Go 템플릿 파일
_helpers.tplnamed template 정의 — _로 시작하여 렌더링 대상 제외
includenamed template 호출 + pipeline 지원
toYaml값 구조체를 YAML 문자열로 직렬화
required필수 값 없을 때 명확한 오류로 렌더링 중단
nindent N앞에 줄바꿈 + N칸 들여쓰기
helm template클러스터 없이 로컬 렌더링 결과 확인
helm lintChart 문법·구조 검증
dependency서브차트 — helm dependency update로 다운로드

명령어·단축키 빠른 참조

이 모듈에서 다룬 Chart 작성·검증·배포 명령과 Go 템플릿 핵심 함수를 모았습니다. "예"의 조합을 그대로 써도 됩니다.

helm CLI — 작성·검증·배포

명령어/단축키용도자주 쓰는 예
helm create차트 골격 생성helm create my-webapp
helm lint차트 문법·구조 검증helm lint ./my-webapp (0 failed면 정상)
helm template클러스터 없이 로컬 렌더링helm template rel ./chart --set image.tag=v2 | grep image:
helm template --show-only특정 템플릿만 렌더helm template rel ./chart --show-only templates/deployment.yaml
helm template --debug렌더 전처리까지 출력(디버깅)helm template rel ./chart --debug 2>&1 | head -50
helm install --dry-run실제 배포 없이 설치 검증helm install my-webapp ./chart -n dev --dry-run
helm dependency updateChart.yaml의 서브차트 다운로드helm dependency update ./my-webapp (charts/에 tgz 생성)
helm upgrade --atomic실패 시 자동 롤백helm upgrade my-webapp ./chart --set image.tag=$SHA --atomic --timeout 5m

Go 템플릿 핵심 함수 (templates/·_helpers.tpl)

함수용도자주 쓰는 예
includenamed template 호출(pipeline 가능)include "my-webapp.labels" . | nindent 4
toYamlvalues 구조체를 YAML로 직렬화toYaml .Values.resources | nindent 12
required필수 값 없으면 오류로 렌더 중단required "image.tag는 필수" .Values.image.tag
default값이 없을 때 기본값.Values.replicaCount | default 1
nindent / indent줄바꿈+들여쓰기 / 들여쓰기만nindent 4 (앞 줄바꿈 포함, 주로 사용)

관련 모듈로 더 깊이:

다음 모듈 gitops-argocd에서는 Git 저장소를 진실의 원본으로 삼아 Helm Chart 배포를 자동화하는 ArgoCD GitOps 워크플로를 다룹니다. Push 한 번으로 멀티 클러스터에 동기화되는 선언적 배포 파이프라인을 구성합니다.

지식 확인

퀴즈 — 8문제

Q1

Chart를 작성 중인데 Deployment, Service, ConfigMap 모두에 동일한 label 블록이 반복된다. 이를 한 곳에서 관리하려면 어떻게 해야 하는가?

Q2

values.yaml에서 정의된 값을 template에서 참조할 때 올바른 문법은?

Q3

Helm의 `required` 함수를 사용하는 이유는?

Q4

`helm template` 명령어의 주된 용도는?

Q5

Helm Chart의 templates/ 디렉토리와 values.yaml의 역할 구분으로 옳은 것은?

Q6

내 Chart가 PostgreSQL을 함께 배포해야 한다. bitnami/postgresql 같은 외부 Chart를 재사용하려면?

Q7

[심화] Deployment를 Helm으로 배포한 뒤 누군가 kubectl scale로 레플리카 수를 직접 바꿨다. Helm은 이 변경을 어떻게 다루는가?

Q8

[심화] CI에서 helm upgrade가 한 번 타임아웃으로 죽은 뒤부터 모든 배포가 another operation is in progress 로 즉시 실패한다. 릴리스는 pending-upgrade 상태다. 가장 적절한 조치는?

0 / 8 답변

🧪 실습으로 확인하기

K8s 기초 — Pod/Deployment/Service 생성

초급

kubectl로 nginx Pod를 생성하고 Deployment와 Service를 차례로 만들어 클러스터 외부에서 접근 가능한 상태까지 구성한다. K8s 3대 리소스의 역할과 관계를 직접 손으로 익힌다.

55📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요