0biglife.

Helm으로 통합 배포 솔루션 구현하기 (with Golang, Harbor)

Productivity/Deployment

· 2025-03-23

Helm으로 통합 배포 솔루션 구현하기 (with Golang, Harbor)

들어가며

이번 게시글은 Helm에 대해 다룬다. 총 6일간 사내 솔루션 설치 방식에 Helm Chart와 Helm Repository를 적용하였으며, csp별 K8s 리소스 통합 과정과 테스트 코드를 포함한 기술 세션 공유를 진행하였다. 솔루션의 첫인상과도 같은 설치 서비스가 정돈되고, 불편한 외부 레지스트리를 걷어내는 유의미한 시간이었다.

배의 운전대, 키를 의미하는 Helm배의 운전대, 키를 의미하는 Helm

Helm

Helm은 Kubernetes 패키지 매니저로 애플리케이션 배포를 쉽고 효율적으로 관리하도록 도와주는 도구다. Node.js로 치면 Npm이라고 생각하면 이해가 쉽다. 장점은 다음과 같다.

  1. 패키징 및 배포: 여러 YAML 파일(Deployment, Service, ConfigMap 등)을 하나의 패키지(Helm Chart)로 관리하여, 복잡한 애플리케이션을 한 번에 배포 가능하게 해준다.

  2. 버전 관리: 애플리케이션의 버전을 관리하고, 롤백(rollback) 기능을 지원하여 안정적인 배포를 보장한다.

  3. 템플릿화: Helm Chart 템플릿을 사용하여 다양한 환경(개발, 테스트, 운영)에 맞게 구성값을 동적으로 주입하여 관리한다.

Helm Chart란

Helm을 사용해 애플리케이션을 배포하기 위한 패키지 파입 집합 개념이다. 이는 Kubernetes 리소스를 정의하는 템플릿 파일과 Chart 자체에 대한 메타데이터를 포함한다.

구성요소

  • Chart.yaml: Chart의 이름, 버전, 설명 등의 메타데이터를 담고 있다.

  • values.yaml: 템플릿에서 사용될 기본 설정값들을 정의한다.

  • templates/: 실제 Kubernetes 리소스(Deployment, Service 등)를 정의하는 YAML 템플릿 파일들을 관리한다.

필자는 CSP별 다른 속성값들을 관리하기 위해 values-* 파일을 통해 관리하려고 한다. 예를 들어, values-aks.yaml, values-eks.yaml, values-nks.yaml 등 추가 설정 파일을 추가하여 각 CSP별 인자들, 이를테면 StorageClass의 provisioner, parameters 속성들을 제어할 수 있다. 따라서, Helm Chart를 통해 우리는 복잡한 Kubernetes 배포 구성을 단순화하고, 동일한 애플리케이션을 다양한 환경에서 단일 차트를 통해 배포 관리하려는 것이다.

개발 요구사항

다음 요건을 충족시키자는 목표로 시작했다.

  1. csp별 서로 다른 yaml 파일 관리를 Helm을 통해 통합한다.

  2. 설치 과정에서 Helm을 설치하지 않고, go-client-helm SDK를 사용함으로써 사용자 PC의 Helm 버전 의존성을 완벽하게 분리하여 설치를 진행한다. (당연히 인스턴스 메모리는 실행 종료시 자동 해제된다.)

  3. 만들어진 Helm 인스턴스를 재사용하여 로직을 간소화, 유지보수 한다. 예를 들어, Cilium과 Istio를 설치하는 함수는 root/helm/installer.go로 분리하여 필요할 때 가져다쓴다.

  4. Helm Chart로 통합될 yaml 관리를 통해 외부 레지스트리(wasabi)를 완전히 걷어낸다.

  5. 이 과정에서 발생되는 모든 함수는 단일 원칙을 따르고, 테스트 코드를 작성하여 관리한다.

이 과정을 진행하기 위해 가장 먼저 go-client-helm 인스턴스를 만드는 것부터 시작했다.

Go Client Helm

다시 말해 필자는 현재 Golang으로 설치 프로그램을 개발 중이며 설치하려는 로컬 PC에 Helm 설치 여부와는 독립적으로 개발하기 위하여 go-helm-client를 사용 중이다.(이는 Helm v2,v3가 보안문제로 인하여 RBAC 관리 방식이 바뀌는 것에 영향을 주지 않기 위함이며, 동시에 설치 과정에서 발생하는 의존성은 설치 종료와 동시에 제거되어야함을 보장하기 위함이다.) 사용법은 간략히 다음과 같다.

1helmClient, err := helmclient.New(opt)
2if err != nil {
3  return fmt.Errorf("helm client creation failed: %w", err)
4}
5
6customValuesYaml := `
7global:
8  csp: nks
9  namespace: test-system
10
11  mongodb:
12    registrySecretName: ncr-secret
13
14base:
15  enabled: false  # base 컴포넌트 활성화
16main:
17  enabled: true   # main 컴포넌트 활성화
18`
19
20ChartSpec := &helmclient.ChartSpec{
21  ChartName:       "oci://{harbor-url}/{project-name}/{chart-name}",  // OCI 레지스트리 차트 위치
22  Version:         "1.0.0",                                           // 차트 버전
23  ReleaseName:     {release-name},                                    // 릴리스 이름
24  Namespace:       {namespace},                                       // 설치될 네임스페이스
25  Wait:            true,                                              // 리소스 Ready 상태 대기
26  Timeout:         time.Minute * 10,                                  // 설치 타임아웃
27  CreateNamespace: true,                                              // 네임스페이스 자동 생성 비활성화
28  ValuesYaml:      customValuesYaml,                                  // 차트 values 설정
29}
30
31fmt.Println("🚀 Starting Custom Chart Installation...")
32if _, err := helmClient.InstallOrUpgradeChart(context.Background(), ChartSpec, nil); err != nil {
33  return err
34}

위와 같이 helmclient라는 인스턴스를 생성하여 재사용한다. customValuesYaml에서는 global 속성을 통해 csp와 각 csp별 다르게 관리하고 싶은 속성값들을 명시해주고 인스턴스의 차트 스펙의 ValuesYaml에 넣어준다. 차트 이름과 Wait은 아래서 설명할 예정이며, Timeout은 현재 차트 설치하는 타임아웃 시간을 지정하는 값이다. CreateNamespace는 설치하려는 리소스의 네임스페이스가 존재하지 않는다면 해당 네임스페이스 생성을 먼저 진행한다는 것을 의미한다.

Helm의 용도와 구성은 알겠고 이젠 내가 원하는 의도에 맞게 동작하게 해야한다. 이를 위한 고민해야할 큰 맥락은 다음 두 가지로 정리했다.

1. csp별 필요한 속성값 관리는 어떻게 이루어지는가?

Helm 고유의 yaml 관리 문법(필자는 문법이라고 표현하겠다)이 처음엔 익숙하지 않았으나, 쓸수록 편리했다. 예를 들어, 인자로 custom.*를 넘겨주고 해당 값을 특정 yaml에서 가져다 쓸 때는 {{ .Values.custom.frontendImage }} 방식으로 가져다 쓴다.

1# Deployment/yaml
2containers:
3  - name: frontend
4    image: { { .Values.custom.frontendImage } }
5    ports:
6      - containerPort: 4173
7        protocol: TCP
8    resources:
9      limits:
10        cpu: "1"
11        memory: 1000Mi
12      requests:
13        cpu: 250m
14        memory: 1000Mi

또는 yaml 내부에서 조건문도 사용 가능하다. global로 선언한 csp 속성값이 nks일 때에는 이 yaml을 배포하지 않겠다는 조건을 다음과 같이 걸어줄 수 있다. 심지어, 우리가 에러 로깅을 찍어보듯이 yaml 내부에서 값을 디버깅할 수도 있다.

1# StorageClass.yaml
2{{- if ne .Values.global.csp "nks" }}
3allowVolumeExpansion: true
4apiVersion: storage.k8s.io/v1
5kind: StorageClass
6metadata:
7  name: test-storageclass
8  namespace: test-system
9  annotations:
10    "helm.sh/hook-weight": "-16"
11mountOptions:
12  - dir_mode=0777
13  - file_mode=0777
14parameters:
15{{- if .Values.storageClass.parameters }}
16  {{- range $key, $value := .Values.storageClass.parameters }}
17  {{ $key }}: {{ $value }}
18  {{- end }}
19{{- end }}
20provisioner: {{ .Values.storageClass.provisioner }}
21reclaimPolicy: Retain
22volumeBindingMode: Immediate
23{{- end }}

2. 설치 순서를 어떻게 적용하는가?

Helm은 Annotation에 hook-weight를 지정해줌으로써 순차적인 배포를 보장한다. 이 숫자는 작는 숫자일수록 최우선으로 배포한다. 예를 들어, A 리소스는 B리소스보다 먼저 배포된다. 또한, helm.sh/resource-policy: keep과 같은 리소스 재배포시 영향을 받을지 여부에 대한 기능도 포함한다. 이 밖에 기능들은 문서에서 확인하자.

1# A Resource
2  ...
3  annotations:
4    "helm.sh/hook-weight": "-6"
5    "helm.sh/resource-policy": keep
6  ...
7
8# B Resource
9  ...
10  annotations:
11    "helm.sh/hook-weight": "-2"
12    "helm.sh/resource-policy": keep
13  ...

3. 설치 도중에 발생하는 DB Replia Set을 PRIMARY로 적용이나 Pod Status 검증은 어떤 식으로 동작해야하는가? 설치를 중단해야할까 아니면 wait을 걸어주는 기능이 지원되는가?

먼저, Pod Status 검증에 대해서는 Helm에서 자동으로 검증해준다. go-client-helmhelmclient에는 Wait 기능을 true 또는 false로 지원해준다. true로 설정할지 현재 설치한 워크로드의 파드 상태가 Ready가 될 때까지 기다려준다는 뜻이다.

1...
22025/01/24 09:25:50 Service does not have load balancer ingress IP address: test-system/test
32025/01/24 09:25:52 Deployment is not ready: test-system/test. 0 out of 1 expected pods are ready
42025/01/24 09:25:54 Deployment is not ready: test-system/test. 0 out of 1 expected pods are ready
52025/01/24 09:25:56 Deployment is not ready: test-system/test. 0 out of 1 expected pods are ready
6...
7# 위와 같이 디폴트 2초에 한 번씩 모니터링하고 상태가 '1 out of 1'이 되면 다음 설치 과정으로 넘어간다.

정말 편리하지 않나. 고마워 Helm.. 그 다음은 DB StatefulSet이 배포가 되었고, 솔루션 워크로드 파드가 설치되기 전에 백엔드 파드가 실행되면서 DB의 PRIMARY POD를 요청해야한다. 즉, 설치 순서 사이에 DB Replica Set 설정을 위해 작업이 중단되어야하는 것을 의미하는데, 이를 위해 서브차트로 분리하였다. 물론 option을 통해 제어할 수 있다고 하지만, 개발 레벨에서의 복잡성과 차트 아키텍처의 복잡성에 대한 밸런스를 고민한 결과 option은 쓰지 않고 Chart를 하나 더 만드는 대신 코드를 깔끔하게 가져가기로 했다. 이는 차트 구성도가 최대 2개까지 가져가는 것이 되려 base subChart, main subChart로 나뉘어지는 데서 더 명시적일 것이라 판단한 결과였다.

따라서, 필자가 구성한 Helm Chart 아키텍처는 다음과 같다.

1# base Chart: jaeger, kube-state-metrics, monitoring, mongodb 등 공통 컴포넌트를 정의
2# main Chart: 실제 서비스 배포를 위한 설정을 포함된다.
3# 여러 values 파일을 통해 AKS, EKS, NKS 등 다양한 환경에 맞는 설정을 분리하여 관리한다.
4# 전체 설치 프로세스는 다음과 같이 단계별로 진행된다.
5
6{name}-chart/
7├── Chart.yaml
8├── values.yaml
9├── values-aks.yaml
10├── values-eks.yaml
11├── values-nks.yaml
12└── charts/
13    ├── base/
14    │   ├── Chart.yaml
15    │   ├── values.yaml
16    │   └── templates/
17    │       ├── _helpers.tpl
18    │       ├── config/
19    │       │   └── config.yaml
20    │       ├── jaeger/
21    │       │   ├── deployment.yaml
22    │       │   └── services.yaml
23    │       ├── kube-state-metrics/
24    │       │   ├── deployment.yaml
25    │       │   ├── ...
26    │       │   └── rbac.yaml
27    │       ├── monitoring/
28    │       │   ├── namespace.yaml
29    │       │   ├── ...
30    │       │   └── rbac.yaml
31    │       ├── storage/
32    │       │   ├── storageclass.yaml
33    │       │   ├── pv.yaml
34    │       │   └── pvc.yaml
35    │       ├── mongodb/
36    │       │   ├── statefulset.yaml
37    │       │   ├── ...
38    │       │   └── rbac.yaml
39    │       └── log/
40    │           └── ...
41    └── {main-chart-name}/
42        ├── Chart.yaml
43        ├── values.yaml
44        └── templates/
45            ├── deployment.yaml
46            └── service.yaml

Helm Doc을 통해 SubChart를 사용했고, 위 구성도를 통해 우리는 base Chart 설치를 마친 뒤, 특정 동작을 완수하고 해당 동작이 마친 다음에서야 main Chart 설치를 진행할 것이다.

Helm Repository

이건 뭘까? 말그대로 Helm Chart를 저장하고 배포할 수 있는 저장소다. Helm을 적용하기 전 우리 팀에서는 솔루션 설치를 위해서 가져와야하는 리소스 yaml들은 전부 wasabi 라는 클라우드 스토리지에 올라가있었다. 근래 솔루션 이미지 레지스트리를 Harbor로 구축하면서 이때다 싶어 wasabi를 걷어내고 Harbor로 통합하고자 했다.

Harbor Dashboard PageHarbor Dashboard Page

Harbor오픈소스 컨테이너 레지스트리로, 클라우드 네이티브 환경에서 컨테이너 이미지와 Helm Chart와 같은 패키지를 안전하게 관리할 수 있는 레지스트리다. 솔루션별, CSP별 이미지가 모두 Harbor의 퍼블릭 프로젝트에 올라가 있으며, 설치에 관여하는 Helm Chart도 모두 Harbor의 새로운 프로젝트로 업로드하여 관리하려고 한다. 이렇게 되면 인스톨 프로세스의 10군데 넘게 들어간 wasabi url들은 모두 제거될 수 있었고 이 참에 지저분하고 통합되지 못한 함수들을 전부 통합시키고 리팩토링하였다.

Helm Repository DiagramHelm Repository Diagram

정리해보자면, Helm을 통해 Helm Chart를 프로젝트 목적에 맞게 구축을 한 뒤, 패키지를 생성하고 Repository에 올린다. 그리고 설치할 때마다 우리는 Helm을 사용해 Harbor에서 Chart를 가져올 것이고, 미리 만들어준 Helm Chart를 통해 알아서 yaml들이 배포되도록 의도한다. 위 그림을 참고하자.

최종 코드

최종 코드는 다음과 같다. go-client-helm 인스턴스를 만들고, Istio를 설치한 이후, 첫 번째 서브차트인 base-chart를 설치한다. 설치가 완료된 후에 Helm에서 DB Pod Status 상태 검증을 마쳤기 때문에 곧바로 DB Replica Set 초기화를 진행한다. 초기화를 마치면 초기화가 잘 진행되었는지 3분의 타임아웃과 5초마다 rs.status()를 검증하며 함수를 마친다. 그 다음에서야 이제 두 번째 서브차트인 main-chart를 설치한다. 여기까지가 Helm이 관여하는 모든 프로세스다.

1package nks
2
3var NewCmd = &cobra.Command{
4Run: func(cmd *cobra.Command, args []string) {
5
6  // ... 생략 ...
7
8  //* Helm 핸들러 초기화
9  helmInstaller, err := installer.NewInstaller("./charts")
10  if err != nil {
11    fmt.Println(aurora.Red(fmt.Sprintf("❌ Helm installer instance creation failed : %v", err)).Bold())
12      os.Exit(1)
13  }
14  defer helmInstaller.Cleanup()
15
16  //* Istio 설치
17  if err := helmInstaller.InstallIstio("1.22.2"); err != nil {
18    fmt.Println(aurora.Red(fmt.Sprintf("❌ Istio Installation failed : %v", err)).Bold())
19    os.Exit(1)
20  }
21
22  //* Install base chart
23  if err := installBaseChart(); err != nil {
24    fmt.Println(aurora.Red(fmt.Sprintf("❌ Base Chart Installation failed : %v", err)).Bold())
25    os.Exit(1)
26  }
27
28  //* Initialize replica set
29  if err := initializeReplicaSet(); err != nil {
30    fmt.Println(aurora.Red(fmt.Sprintf("❌ Initialize Replica Set failed : %v", err)).Bold())
31    os.Exit(1)
32  }
33
34  //* Initialize nks credential
35  if err := initializeDBEnvironment(); err != nil {
36    fmt.Println(aurora.Red(fmt.Sprintf("❌ Initialize Nks Credential Setting failed : %v", err)).Bold())
37    os.Exit(1)
38  }
39
40  //* Install main chart
41  if err := installMainChart(); err != nil {
42    fmt.Println(aurora.Red(fmt.Sprintf("❌ Main Chart Installation failed : %v", err)).Bold())
43    os.Exit(1)
44  }
45
46  //* Initialize Service Url
47  if err := initializeServiceUrl(); err != nil {
48    fmt.Println(aurora.Red(fmt.Sprintf("❌ Main Url Initialization failed : %v", err)).Bold())
49    os.Exit(1)
50  }
51
52  //* Send Installation Confirmation Email
53  if err := sendInstallationConfirmationEmail(); err != nil {
54    fmt.Println(aurora.Red(fmt.Sprintf("❌ Send Service Confirmation Email failed : %v", err)).Bold())
55    os.Exit(1)
56  }
57
58  fmt.Println("✅ NKS installation completed successfully!")
59  os.Exit(1)
60},
61}

원래 코드를 정리하면서 책상 정리되듯한 희열을 느낀다. 엉망이었던 코드들이 블록형태로 맞춰지는 쾌감이란.. 이제 저 모듈들을 csp별 원하는 과정마다 끼워넣기만 하면 된다. 물론 100% 완벽하지 않다. 추가 고려사항은 다음과 같다.

  1. 로깅 개선

  2. 에러 모듈화

  3. execMongoDBCommand(namespace, podName, dbCommand string) error 같은 함수로 캡슐화

이후 프로세스

Helm Package 생성

Helm Chart를 만들기 위해 다음 커맨드를 실행한다.

1helm package {package-name}
2
3# 커맨드 결과
4Successfully packaged chart and saved it to: /Users/0ds/Documents/GitHub/install-software/{package-name}-1.0.0.tgz

그럼 root 경로에 {package-name}-1.0.0.tgz 파일이 생성된다.

Harbor Repository에 Chart 파일 push

생성된 파일을 Helm Repository로 쓰려는 Harbor 경로로 푸시해주자.

1helm push {package-name}-1.0.0.tgz oci://{public-harbor-path}/{project-name}
2
3# 커맨드 결과
4Pushed: {harbor-url}/{project-name}/{package-name}:1.0.0
5Digest: sha256:1582fb49c2ad1cecf19037697cc6206014f0b771412f414d86609dc5000c5aa2 # 실제값 아님

그럼 다음과 같이 성공적으로 업로드된 것을 확인할 수 있다. (현재는 버전 1.0.0로 구축된 상태이다.)

Go 설치 파일 생성

1# For M1/M2 Mac
2GOOS=darwin GOARCH=arm64 go build

Go 설치 파일 실행

1# 파일 권한 수정
2chmod +x {install-file-name}
3
4# 파일 실행 : nks 인자 전달을 통해 naver cloud 환경에 솔루션을 설치하도록 실행한다
5./{install-file-name} nks

설치과정 모니터링

좌측 상단은 DB 포트 포워딩하는 용도, 좌측 하단과 중앙은 각각 설치 모니터링과 파드 상태 모니터링, 그리고 우측은 차례대로 pv, pvc, configmap이 배포가 잘 되고 있는지 모니터링하는 터미널 화면이다.

1# port-forward : 배포된 StatefulSet Pod DB 를 Mongo Compass 연결하는 용도
2kubectl port-forward {db-pod-name} -n {namespace} 28015:27017
3
4# pod monitoring : watch 옵션 사용
5kubectl get po -A -w
6
7# pv/pvc monitoring : pv는 네임스페이스가 없으나 습관처럼 -A 달아버리기,,!
8kubectl get pv -A -w
9kubectl get pvc -A -w
10
11# configmap monitoring
12kubectl get configmap -A -w

마치며

Helm을 통해 사내 설치 과정을 효율적으로 배포 및 관리하는 방식에 대해 다뤘다. 그 과정에서 여태 빠져있던 테스트 코드 작성과 코드 정리까지 진행하였다. 답답함이 해소되었으나 늘 그렇듯 하면 할수록 욕심이 생기는 작업이다. 기존에 작업해오면서 문서화한 내용을 기반으로 글을 정리했는데 좀 더 다듬어야할 부분이 보여 다음주에 퇴고해볼 예정이다.

Index

들어가며HelmHelm Chart란구성요소개발 요구사항Helm Repository최종 코드마치며