컴퓨터공학 300 주제 시리즈의 229번째 글이다. 전체 지도는 여기.

한 줄 요약

헬름은 Go 템플릿으로 쓴 매니페스트 묶음(차트)에 값(values)을 넣어 렌더링하고, 그 결과를 하나의 “릴리스”로 설치·업그레이드·롤백하는 쿠버네티스 패키지 관리자다.

왜 필요한가

웹 애플리케이션 하나를 쿠버네티스에 올리려면 디플로이먼트, 서비스, 인그레스, 컨피그맵, 시크릿, 서비스어카운트 등 매니페스트가 여러 개 필요하다. 이것을 개발·스테이징·운영 세 환경에 배포하면 대부분은 같고 이미지 태그, 레플리카 수, 도메인, 자원 한도만 다르다. 파일을 세 벌 복사하면 한쪽만 고치는 실수가 반드시 생긴다.

또 남이 만든 소프트웨어(데이터베이스, 모니터링 스택, 인그레스 컨트롤러)를 설치할 때 매니페스트 수십 개를 직접 관리하고 싶은 사람은 없다. 헬름은 이 둘을 해결한다. 공통 부분은 템플릿으로, 다른 부분은 값으로 분리하고, 설치 단위를 버전으로 관리한다.

핵심 개념

세 가지 용어

용어 뜻
차트(chart) 쿠버네티스 리소스를 만드는 데 필요한 템플릿과 메타데이터의 묶음
저장소(repository) 차트를 모아 두고 배포하는 곳. OCI 레지스트리에 차트를 올릴 수도 있다
릴리스(release) 차트를 클러스터에 설치한 하나의 인스턴스. 같은 차트를 이름만 달리해 여러 번 설치할 수 있다

차트 구조

mychart/
  Chart.yaml          # 이름, 버전(version), 앱 버전(appVersion), 의존 차트
  values.yaml         # 기본값
  values.schema.json  # (선택) 값 검증 스키마
  templates/          # Go 템플릿으로 쓴 매니페스트
    deployment.yaml
    service.yaml
    _helpers.tpl      # 재사용 템플릿 조각(밑줄로 시작하면 매니페스트로 렌더링 안 됨)
    NOTES.txt         # 설치 후 출력되는 안내문
  charts/             # 의존 차트

Chart.yaml 의 version 은 차트 자체의 버전(SemVer 2)이고, appVersion 은 차트가 배포하는 애플리케이션의 버전이다. 둘은 독립적이다. 템플릿만 고쳐도 version 을 올린다.

템플릿과 값

템플릿은 Go 의 text/template 문법에 Sprig 함수 등을 더한 것이다.

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "mychart.fullname" . }}
  labels:
    app.kubernetes.io/name: {{ .Chart.Name }}
    app.kubernetes.io/instance: {{ .Release.Name }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app.kubernetes.io/instance: {{ .Release.Name }}
  template:
    metadata:
      labels:
        app.kubernetes.io/instance: {{ .Release.Name }}
    spec:
      containers:
        - name: app
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          {{- with .Values.resources }}
          resources:
            {{- toYaml . | nindent 12 }}
          {{- end }}
# values.yaml
replicaCount: 2
image:
  repository: nginx
  tag: ""
resources: {}

자주 쓰는 내장 객체는 .Values(값), .Release(릴리스 이름·네임스페이스 등), .Chart(Chart.yaml 내용), .Capabilities(클러스터 버전·지원 API) 다.

공백 제어도 알아 둔다. {{- 는 앞쪽 공백·줄바꿈을, -}} 는 뒤쪽을 지운다. YAML 은 들여쓰기가 문법이므로, toYaml 결과를 nindent 로 정확한 깊이에 맞추는 일이 템플릿 작성의 상당 부분을 차지한다.

값의 우선순위

값은 여러 곳에서 들어와 병합된다. 뒤의 것이 앞의 것을 덮어쓴다.

  1. 차트의 values.yaml
  2. 부모 차트의 values (의존 차트인 경우)
  3. -f / --values 로 준 파일(여러 개면 뒤 파일이 우선)
  4. --set, --set-string 등으로 준 개별 값

맵은 깊게 병합되지만 리스트는 통째로 교체된다. 기본값에 컨테이너 포트 두 개가 있고 오버라이드 파일에 하나만 적으면 결과는 하나다.

릴리스 수명주기

helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install web ./mychart -n web --create-namespace -f values-prod.yaml
helm upgrade web ./mychart -n web -f values-prod.yaml --set image.tag=1.2.3
helm history web -n web           # 리비전 목록
helm rollback web 3 -n web        # 리비전 3으로
helm uninstall web -n web
helm template web ./mychart -f values-prod.yaml   # 클러스터 없이 렌더링만

헬름 3 은 릴리스 정보를 해당 네임스페이스의 시크릿으로 저장한다. 업그레이드할 때마다 리비전이 하나씩 늘고, 롤백도 새 리비전으로 기록된다. helm upgrade --install 은 릴리스가 없으면 설치, 있으면 업그레이드라서 CI 에서 많이 쓴다.

훅(hook)

helm.sh/hook 어노테이션을 단 리소스는 설치·업그레이드 전후 같은 특정 시점에 실행된다. DB 마이그레이션 잡을 pre-upgrade 훅으로 거는 것이 대표적이다. 훅 리소스는 일반 릴리스 리소스와 수명 관리가 다르므로 삭제 정책(helm.sh/hook-delete-policy)을 함께 정한다.

직접 해 보기

헬름의 값 병합 규칙(맵은 깊게, 리스트는 교체)과 간단한 렌더링을 파이썬으로 재현해 보자.

import copy, re

def deep_merge(base, override):
    out = copy.deepcopy(base)
    for k, v in override.items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = deep_merge(out[k], v)
        else:
            out[k] = copy.deepcopy(v)   # 스칼라·리스트는 통째로 교체
    return out

def set_flag(values, expr):  # --set a.b.c=v
    path, val = expr.split("=", 1)
    keys, node = path.split("."), values
    for k in keys[:-1]:
        node = node.setdefault(k, {})
    node[keys[-1]] = int(val) if val.isdigit() else val
    return values

def render(tpl, values, release):
    def lookup(m):
        expr = m.group(1).strip()
        if expr == ".Release.Name":
            return release
        node = values
        for k in expr.removeprefix(".Values.").split("."):
            node = node[k]
        return str(node)
    return re.sub(r"\{\{\s*(.*?)\s*\}\}", lookup, tpl)

chart_values = {"replicaCount": 1,
                "image": {"repository": "nginx", "tag": "1.27"},
                "ports": [80, 443]}
prod_file = {"replicaCount": 3, "image": {"tag": "1.27.2"}, "ports": [8080]}

values = deep_merge(chart_values, prod_file)
values = set_flag(values, "image.tag=1.27.3")
print(values)

tpl = """name: {{ .Release.Name }}-web
replicas: {{ .Values.replicaCount }}
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}"""
print(render(tpl, values, "shop"))

결과는 다음과 같다.

{'replicaCount': 3, 'image': {'repository': 'nginx', 'tag': '1.27.3'}, 'ports': [8080]}
name: shop-web
replicas: 3
image: nginx:1.27.3

image.repository 는 병합으로 살아남았고, ports 는 두 개에서 하나로 교체됐다. --set 이 파일보다 우선했다. 실제 헬름은 여기에 템플릿 함수, 조건문, 스키마 검증이 더해질 뿐 병합의 뼈대는 같다.

현업에서는

  • 렌더링 결과를 먼저 본다. helm template 이나 helm upgrade --dry-run 으로 실제 매니페스트를 확인하고 적용한다. 들여쓰기 하나가 틀리면 필드가 엉뚱한 곳에 붙어 조용히 무시될 수 있다. helm diff 플러그인을 쓰면 클러스터의 현재 상태와 차이를 볼 수 있다.
  • Jekyll·Jinja 와의 충돌. 헬름 템플릿을 문서나 다른 템플릿 엔진 안에 넣으면 이중 중괄호가 그쪽 엔진에 먼저 해석된다. 블로그(Jekyll)에서는 raw 블록으로 감싸야 하고, Ansible 에서 헬름 값 파일을 만들 때도 같은 문제가 생긴다.
  • GitOps 와 함께. ArgoCD 같은 도구는 헬름 차트를 직접 렌더링해서 적용한다. 이때 헬름 릴리스 객체 대신 ArgoCD 가 상태를 관리하므로 helm list 에 보이지 않을 수 있다(230번 주제).
  • 시크릿을 values 에 넣지 않는다. values 파일은 Git 에 올라간다. 비밀값은 외부 시크릿 관리 도구나 암호화된 시크릿(SOPS, Sealed Secrets 등)으로 분리한다.
  • 차트 버전 고정. 외부 차트를 쓸 때는 --version 으로 차트 버전을 고정한다. 고정하지 않으면 다음 설치 때 기본값이 바뀐 새 차트가 들어올 수 있다.

확인 문제

  1. 차트, 저장소, 릴리스를 한 문장씩 정의하라.
  2. Chart.yaml 의 version 과 appVersion 의 차이는?
  3. 기본 values 에 ports: [80, 443], -f 파일에 ports: [8080] 이 있다. 결과는?
  4. --set 과 -f 로 같은 키를 주면 어느 쪽이 이기는가?
  5. 헬름 3 은 릴리스 이력을 어디에 저장하는가?

풀이

  1. 차트: 리소스 템플릿과 메타데이터 묶음. 저장소: 차트를 모아 배포하는 곳. 릴리스: 차트를 클러스터에 설치한 인스턴스.
  2. version 은 차트 패키지의 버전, appVersion 은 배포되는 애플리케이션의 버전이다.
  3. [8080]. 리스트는 병합되지 않고 교체된다.
  4. --set 이 우선한다.
  5. 릴리스가 설치된 네임스페이스의 시크릿(기본 저장 드라이버)에 리비전별로 저장한다.

더 읽을거리 (References)