Istio의 Ambient mode 도입을 위해 리서치를 진행하던 중, 일부 설정 값이 예상과 달리 적용되지 않는 문제를 마주했다. 원인을 파악하기 위해 Istio의 Helm 차트를 깊이 있게 분석했고, 이 과정에서 알게 된 Profile 주입 방식과 차트 템플릿 렌더링 순서에 대한 내용을 공유하고자 한다.
Helm 차트란?
Helm 차트는 쿠버네티스(Kubernetes) 리소스를 템플릿화하여 재사용성과 유지보수성을 높여주는 패키징 도구다. 애플리케이션 배포에 필요한 매니페스트(Manifest)들을 하나의 패키지로 묶어 관리할 수 있게 해주며, 쿠버네티스 생태계에서 사실상 표준으로 자리 잡았다.
우리 팀은 모든 애플리케이션을 Helm 차트로 구성하여 운영 중이며, ArgoCD와 결합해 GitOps 방식으로 배포를 관리하고 있다.
주입한 값이 적용되지 않는 문제
Istio 차트를 다루던 중 다음과 같은 상황이 발생했다.
# 차트의 기본 values 파일
...
cniBinDir: /opt/cni/bin
...
# profile-platform-gke 파일
cni:
cniBinDir: ""
# 사용자 정의 values 파일
cniBinDir: mypath
일반적으로 Helm에서는 차트에 포함된 기본 values보다 사용자가 직접 주입하는 values의 우선순위가 더 높다. 따라서 위와 같이 배포할 경우 cniBinDir에 mypath 값이 적용될 것이라 예상했으나, 실제로는 profile-platform-gke.yaml 파일에 정의된 빈 문자열 값이 적용되는 현상이 발생했다.
Istio Helm 차트 분석
Istio의 Ambient mode를 사용하려면 istiod, base, cni, ztunnel 등 여러 차트를 한 번에 배포해야 한다. 이때 특정 모드에 대한 값을 각각의 차트에 개별적으로 정의하는 번거로움을 줄이기 위해, Istio는 Profile이라는 개념을 도입해 모드별 값을 한 곳에서 통합 관리한다.
istio-cni 차트 트리 구조
├── files
│ ├── profile-ambient.yaml
│ ├── profile-platform-gke.yaml
├── templates
│ ├── daemonset.yaml
│ ├── zzy_descope_legacy.yaml
│ └── zzz_profile.yaml
├── README.md
├── Chart.yaml
└── values.yaml
Istio CNI 차트의 구조를 살펴보면 일반적인 차트와 달리 files 폴더에 Profile 관련 파일이, templates 폴더에는 zzz_profile.yaml, zzy_descope_legacy.yaml과 같은 특수한 파일이 존재함을 알 수 있다.
Profile 사용 방법
helm install istio-cni istio/cni \
--set profile=ambient \
--set global.platform=gke \
-n istio-system --wait
profile=ambient를 주입하면 profile-ambient.yaml의 값이 병합된다.
global.platform=gke를 주입하면 profile-platform-gke.yaml의 값이 추가로 병합된다.
즉, 프로필을 지정하면 차트의 기본 values가 아닌 전용 profile 파일이 먼저 적용되고, 이후 사용자가 주입한 값이 덮어쓰게 된다.
원인 분석: 템플릿 로직과 값의 병합
1. values.yaml의 구조
차트의 기본값들은 _internal_defaults_do_not_set이라는 키 아래에 래핑되어 있다.
_internal_defaults_do_not_set:
logging:
level: ""
cniBinDir: /opt/cni/bin
ambient:
enabled: false
CNI 차트에 적용되어야 할 값이 cni 키 아래에 래핑되어 있다.
cni:
cniBinDir: ""
resourceQuotas:
enabled: true
resourceQuotas:
enabled: true
3. templates/zzz_profile.yaml의 역할
1) 기본 values를 $defaults로 설정
{{- $defaults := $.Values._internal_defaults_do_not_set }}
차트의 기본 values의 _internal_defaults_do_not_set 값을 $defaults 변수로 정의한다.
2) profile 파일 로딩 → $profile
{{- with (coalesce ($.Values).profile ($.Values.global).profile) }}
{{- with $.Files.Get (printf "files/profile-%s.yaml" .)}}
{{- $profile = (. | fromYaml) }}
{{- else }}
{{ fail (cat "unknown profile" .) }}
{{- end }}
{{- end }}
여기서 사용된 coalesce 함수는 나열된 인자 중 null이 아닌 첫 번째 값을 반환한다. 즉, $.Values.profile을 먼저 확인하고 없으면 $.Values.global.profile을 확인한다. 이 값이 정의되어 있다면 files 폴더에서 해당 파일을 가져와 $profile 변수에 할당한다.
3) $profile 병합
{{- with (coalesce ($.Values).platform ($.Values.global).platform) }}
{{- with $.Files.Get (printf "files/profile-platform-%s.yaml" .) }}
{{- $ignore := mustMergeOverwrite $profile (. | fromYaml) }}
{{- else }}
{{ fail (cat "unknown platform" .) }}
{{- end }}
{{- end }}
platform 값이 정의되어 있다면 해당 파일을 가져와 $profile 변수에 mustMergeOverwrite로 병합한다. mustMergeOverwrite는 Helm 템플릿 함수로, 두 번째 인자(오른쪽)의 값이 첫 번째 인자(왼쪽)를 덮어쓰는 방식으로 동작한다. 따라서 기존 $profile 값 위에 플랫폼 전용 설정이 덮어씌워진다.
4) 최종 병합
{{- if $profile }}
{{- $a := mustMergeOverwrite $defaults $profile }}
{{- end }}
{{- $b := set $ "Values" (mustMergeOverwrite $defaults $.Values) }}
defaults에 $profile을 병합하고, 마지막으로 사용자 정의 값($.Values)을 덮어쓴 뒤 $.Values를 재할당한다.
차트의 기본 values 값은 _internal_defaults_do_not_set이라는 키로 wrapping 되어 있으므로 병합하기 전 $.Values의 실질적인 값은 사용자가 –set 또는 -f로 주입한 값이다.
이 시점까지의 우선순위는
- 사용자 정의 values
- profile-platform
- profile
- 차트 기본 values 순서다.
4. templates/zzy_descope_legacy.yaml의 역할
# $.Values 값에 $.Values.cni 값 병합
{{- $_ := mustMergeOverwrite $.Values (index $.Values "cni") }}
이 템플릿은 $.Values 전체에 $.Values.cni 값을 강제로 덮어씌운다. 이 로직으로 인해 우선순위가 다음과 같이 재정렬된다.
- 사용자 정의 values 파일의
cni 키 내부 값
- profile-platform 파일의
cni 키 내부 값
- profile 파일의
cni 키 내부 값
- (그 외) 사용자 정의 values (루트 레벨)
- profile-platform (루트)
- profile (루트)
- 차트의 기본 values
즉, Profile 내부에서 cni라는 키로 래핑된 설정들이 루트 레벨에 있는 설정보다 더 높은 우선순위를 갖게 된다. 이 때문에 사용자가 루트 레벨에 cniBinDir: mypath를 정의하더라도, Profile에 정의된 빈 문자열("")이 최종적으로 이를 덮어써 적용된다.
따라서 Istio 차트에서 profile 기능을 사용하는 경우, 내가 변경하려는 값이 profile 내부에서 어떻게 래핑되어 있는지 반드시 확인해야 한다.
값 병합 과정 시뮬레이션
이해를 돕기 위해 cniBinDir 값이 어떻게 변하는지 단계별로 추적해 보았다.
1. ToBe
# daemonset.yaml
{{- if .Values.cniBinDir }}
{{ $detectedBinDir = .Values.cniBinDir }}
{{- end }}
kind: DaemonSet
apiVersion: apps/v1
spec:
template:
metadata:
volumes:
- name: cni-bin-dir
hostPath:
path: {{ $detectedBinDir }}
2. 차트의 기본 values.yaml 파일
_internal_defaults_do_not_set:
cniBinDir: /opt/cni/bin
4. 사용자 정의 values.yaml 파일
global:
platform: gke
cniBinDir: mypath
5. zzz_profile.yaml 렌더링
Helm은 template 내에 있는 파일에 대해 사전 역순으로 렌더링된다.
1) 차트의 기본 values.yaml 파일의 값을 $defaults 변수로 정의
# .values 파일의 _internal_defaults_do_not_set로 되어 있는 값을 $default 변수로 정의
{{- $defaults := $.Values._internal_defaults_do_not_set }}
1. === $defaults ===
cniBinDir: /opt/cni/bin
{{- $profile := dict }}
{{- with (coalesce ($.Values).platform ($.Values.global).platform) }}
{{- with $.Files.Get (printf "files/profile-platform-%s.yaml" .) }}
{{- $ignore := mustMergeOverwrite $profile (. | fromYaml) }}
{{- else }}
{{ fail (cat "unknown platform" .) }}
{{- end }}
{{- end }}
2. === $profile ===
cni:
cniBinDir: ""
{{- if $profile }}
{{- $a := mustMergeOverwrite $defaults $profile }}
{{- end }}
# 병합 결과 $defaults 변수의 상태
3. === $defaults ===
cni:
cniBinDir: ""
cniBinDir: /opt/cni/bin
4) 5-3에서 병합된 값과 사용자 정의 values.yaml 파일의 값 병합 후 .Values로 재정의
{{- $b := set $ "Values" (mustMergeOverwrite $defaults $.Values) }}
4. === $.Values ===
cni:
cniBinDir: ""
cniBinDir: mypath # 사용자가 정의한 값이 주입됨
6. zzy_descope_legacy.yaml 실행
# .Values.cni에 정의되어 있는 값을 .Values 값과 병합
{{- $_ := mustMergeOverwrite $.Values (index $.Values "cni") }}
5. === $.Values ===
cniBinDir: "" # profile에서 주입된 값으로 병합됨
7. 결과
사용자 정의 values 에서 cniBinDir: mypath 로 지정했지만, zzy_descope_legacy.yaml 파일이 렌더링 되면서 profile-platform-gke.yaml 값으로 mypath가 덮어써지는 것을 볼 수 있다.
# 사용자 정의 values.yaml 파일
global:
platform: gke # profile 사용
cniBinDir: mypath # 잘못된 방식
cni:
cniBinDir: mypath # 올바른 방식
Istio 차트에서 Profile을 사용할 때, 수정하려는 값이 Profile 내부에 어떻게 정의되어 있는지 확인이 필수적이다.
- 잘못된 방식:
cniBinDir: mypath (루트 레벨 선언)
- 올바른 방식:
cni: { cniBinDir: mypath } (구조에 맞춘 선언)
Profile 활용법: 우리 팀 적용 방안
현재 우리 팀은 ‘V25’, ‘MLB Rivals’, ‘프로야구 Rising’ 등 세 개의 야구 게임을 운영하고 있다. 동일한 Helm 차트를 사용하지만 게임별, 환경별(Dev/Staging/Live) 요구사항이 달라 values.yaml 관리가 복잡해지는 문제가 있었다.
Istio의 사례를 벤치마킹하여 Profile 개념을 도입하면 중복 설정을 줄이고 가독성을 높일 수 있다.
- 게임별 Profile: Image Registry 등 게임 고유 값 정의 (
profile-v25.yaml)
- 환경별 Profile: 로그 레벨 등 환경 고유 값 정의 (
profile-dev.yaml)
# profile-v25.yaml 예시
global:
image:
registry: v25-registry
# profile-dev.yaml 예시
global:
logging:
level: debug
이렇게 관리하면 “어떤 설정이 적용되었는가”를 파일 이름만으로 직관적으로 파악할 수 있어 유지보수성이 크게 향상된다.
Helm Template 파일 렌더링 순서 심층 분석
Helm 공식 문서에는 템플릿 파일의 명확한 렌더링 순서가 명시되어 있지 않다. 정확한 동작 이해를 위해 Helm의 Go 소스 코드를 분석해 보았다.
소스 코드 위치: helm/pkg/engine/engine.go
func sortTemplates(tpls map[string]renderable) []string {
keys := make([]string, len(tpls))
i := 0
for key := range tpls {
keys[i] = key
i++
}
sort.Sort(sort.Reverse(byPathLen(keys)))
return keys
}
type byPathLen []string
func (p byPathLen) Len() int { return len(p) }
func (p byPathLen) Swap(i, j int) { p[j], p[i] = p[i], p[j] }
func (p byPathLen) Less(i, j int) bool {
a, b := p[i], p[j]
ca, cb := strings.Count(a, "/"), strings.Count(b, "/")
if ca == cb {
return strings.Compare(a, b) == -1
}
return ca < cb
}
이 코드는 템플릿 파일들을 정렬하는 핵심 로직이다. byPathLen을 통해 파일 경로의 깊이(Depth)를 우선 기준으로 삼고, sort.Reverse를 통해 역순 정렬을 수행한다. 깊이가 같은 경우 파일 이름의 사전 역순(Reverse Alphabetical)으로 정렬된다.
이 정렬 로직 때문에 templates 폴더 내 파일들은 다음과 같은 순서로 렌더링된다.
정렬 전:
/templates
├── deployment.yaml
├── zzz_profile.yaml
└── zzy_descope_legacy.yaml
정렬 후 (실제 렌더링 순서):
/templates
├── zzz_profile.yaml (이름이 'z'로 시작하여 가장 뒤쪽이지만 역순 정렬로 가장 먼저 실행)
├── zzy_descope_legacy.yaml
└── deployment.yaml
이 순서 덕분에 zzz_profile.yaml과 zzy_descope_legacy.yaml이 deployment.yaml보다 먼저 실행되어 $.Values를 변조할 수 있었고, 이후 렌더링되는 리소스들은 변조된 값을 참조하게 되는 것이다.
마무리
지금까지는 Helm 차트의 기본적인 기능만 활용해 왔지만, 이번 Istio 차트 분석을 통해 차트를 훨씬 더 깊이 있게 다루는 방법을 배울 수 있었다. 특히 Profile을 통한 설정 관리와 템플릿 렌더링 순서를 이용한 전처리(Pre-processing) 기법은 매우 인상적이었다.
이러한 심화 활용법을 우리 팀의 운영 환경에도 적용하여, 더욱 확장성 있고 유연한 배포 파이프라인을 구축해 나갈 예정이다.