팀마다 비슷한 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 version --shortkubectl cluster-infokubectl create namespace helm-devmkdir -p ~/helm-workshop && cd ~/helm-workshophelm 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를 만들 수 있습니다.
확대
Chart 기본 구조
my-webapp/ 구조입니다.
Chart.yaml— Chart 메타데이터 (필수)values.yaml— 기본값 정의 (필수)charts/— 서브차트(dependency) 저장 디렉토리templates/— Kubernetes manifest 템플릿_helpers.tpl— 재사용 named template (렌더링 대상 아님)deployment.yaml,service.yaml,ingress.yaml,configmap.yamlNOTES.txt— helm install 후 출력될 안내 메시지
Chart.yaml — Chart 메타데이터
# 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 — 기본값 정의
확대
# 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 — 핵심 템플릿
# 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)이 아니라, 내가 만든 차트가 어떻게 렌더되는지의 관점입니다.
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를 읽고 디버깅하는 속도도 빨라집니다.
확대
include — named template 호출 (pipeline 지원)
# 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로 직렬화
# 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 — 필수 값 검증
# 값이 비어 있으면 오류 메시지와 함께 렌더링 중단
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 — 기본값 설정
# .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
# 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 전체 예시
{{/*
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에서 컨텍스트 전달
# . (현재 컨텍스트 전체)를 전달하는 것이 일반적
{{- 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 오버라이드
# values.yaml — 서브차트 설정은 서브차트 이름 아래에 작성
redis:
enabled: true # condition 값 (이 키로 서브차트 활성화 제어)
auth:
enabled: true
password: "" # 값은 Secret 참조 또는 별도 보호된 values 파일로 주입
master:
persistence:
size: 5Gi
replica:
replicaCount: 1
서브차트 내부 Service 이름 참조
# 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>로 실제 적용된 값 확인
Chart 골격 생성 및 구조 확인
mkdir -p ~/helm-workshop/my-webapp/{templates,charts}
cd ~/helm-workshop/my-webapp
ls -R .예상 출력
.: Chart.yaml charts templates values.yaml ./charts: ./templates:
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
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
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"클러스터에 설치 및 상태 확인
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은 공백에 매우 민감합니다.
# 잘못된 _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)
# .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.yaml | Chart 이름, 버전, dependency 정의 |
| values.yaml | 기본값 정의 — 환경별 오버라이드 가능 |
| templates/ | Kubernetes manifest Go 템플릿 파일 |
| _helpers.tpl | named template 정의 — _로 시작하여 렌더링 대상 제외 |
include | named template 호출 + pipeline 지원 |
toYaml | 값 구조체를 YAML 문자열로 직렬화 |
required | 필수 값 없을 때 명확한 오류로 렌더링 중단 |
nindent N | 앞에 줄바꿈 + N칸 들여쓰기 |
helm template | 클러스터 없이 로컬 렌더링 결과 확인 |
helm lint | Chart 문법·구조 검증 |
| 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 update | Chart.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)
| 함수 | 용도 | 자주 쓰는 예 |
|---|---|---|
include | named template 호출(pipeline 가능) | include "my-webapp.labels" . | nindent 4 |
toYaml | values 구조체를 YAML로 직렬화 | toYaml .Values.resources | nindent 12 |
required | 필수 값 없으면 오류로 렌더 중단 | required "image.tag는 필수" .Values.image.tag |
default | 값이 없을 때 기본값 | .Values.replicaCount | default 1 |
nindent / indent | 줄바꿈+들여쓰기 / 들여쓰기만 | nindent 4 (앞 줄바꿈 포함, 주로 사용) |
관련 모듈로 더 깊이:
- 복잡한 매니페스트를 차트(Chart) 단위로 원클릭 배포하기 — Chart를 직접 만들기 전에 먼저 익혀야 할 사용·오버라이드 기본
- CRD(Custom Resource Definition)와 쿠버네티스 API 정의 — Chart에 CRD를 포함해 API를 확장하는 패턴
- ArgoCD로 소스 코드 저장소와 클러스터 상태 동기화하기 — 완성한 Chart를 Git 기반으로 자동 배포하는 법
다음 모듈 gitops-argocd에서는 Git 저장소를 진실의 원본으로 삼아 Helm Chart 배포를 자동화하는 ArgoCD GitOps 워크플로를 다룹니다. Push 한 번으로 멀티 클러스터에 동기화되는 선언적 배포 파이프라인을 구성합니다.