[Kubernetes/Helm] 헬름(Helm)으로 서버 애플리케이션 배포하기(node.js + mysql)
Helm은 쿠버네티스에서 원하는 리소스를 세트로 하여 한번에 패키지로 설치할 수 있게 도와주는 툴입니다. 그래서 상세 파일과는 또 다른 방법으로 한 개의 프로그램 패키지를 솝쉽게 설치할 수 있습니다.
헬름으로 직접 서버를 배포했을 때 느낀 점은, 애플리케이션의 의존성을 한 번에 같이 설치할 수 있다는 점이었습니다. 또한, git처럼 각 패키지를 버전으로 관리할 수 있기 때문에 각기 다른 상태로 사용할 수 있다는 것이 또다른 장점입니다.
그래서 이번에 Helm으로 서버 애플리케이션과 mysql을 함께 배포하고 이를 하나의 차트로 만들어서 사용하고자 합니다. 해당 예시에서 사용한 도구는 minikube(docker-driver), helm, node.js, mysql입니다.
차트 초기화
먼저 헬름은 차트라고 하는 쿠버네티스 리소스의 세트를 사용합니다. 예를 들어, 애플리케이션의 디플로이먼트와 서비스, 시크릿 및 롤/롤바인딩 등 필요한 모든 리소스를 차트에 담아 패키지로 설치합니다.
지금 필요한 것은 서버 애플리케이션과 mysql 데이터베이스이므로, 이 두 가지를 중점적으로 차트를 개발하면 됩니다.
차트는 helm 명령어로 생성할 수 있습니다. 저는 nodejs-server라는 이름으로 차트를 생성했습니다.
helm create <차트명>
차트는 아래와 같은 구조를 가집니다.
chart/
# ----- 필수 -----
Chart.yaml # 차트에 대한 정보를 가진 YAML 파일
values.yaml # 차트에 대한 기본 환경설정 값들
charts/ # 이 차트에 종속된 차트들을 포함하는 디렉터리
crds/ # 커스텀 자원에 대한 정의
templates/ # values와 결합될 때, 유효한 쿠버네티스 manifest 파일들이 생성될 템플릿들의 디렉터리
# ----- 옵션 -----
LICENSE # 옵션: 차트의 라이센스 정보를 가진 텍스트 파일
README.md # 옵션: README 파일
values.schema.json # 옵션: values.yaml 파일의 구조를 제약하는 JSON 파일
templates/NOTES.txt # 옵션: 간단한 사용법을 포함하는 텍스트 파일
helm v3 부터는 내가 만들 차트에 필요한 의존성을 requirement.yaml이 아니라 Chart.yaml에 한꺼번에 작성합니다.
여기서 주목할 것은 Chart.yaml
, values.yaml
, templates
이렇게 세 가지 입니다.
Chart.yaml은 차트에 관한 상세 파일입니다. 최초로 아래의 데이터들을 가집니다.
apiVersion: v2
name: nodejs-server
description: A simple HTTP API server for the kubernetes.
type: application
version: 0.1.0
appVersion: "0.1.0"
- apiVersion: helm v3부터는 v2 api version을 사용
- name: 차트 이름
- description: 차트 설명
- type: 차트 타입 - application과 library 타입이 있으며 기본적으로 application 타입입니다. application은 기본 타입으로 온전하게 작동할 수 있는 차트이며, library는 차트 빌더에 유틸리티나 함수를 제공하기에 설치가 불가능하고 리소스 오브젝트를 갖지 않습니다.
- version: 차트의 버전입니다.
- appVersion: version과 다른 값이며 필수값은 아닙니다. yaml을 파싱하면서 값이 원치 않은 값으로 인식될 수 있으므로 반드시 따옴표를 붙여줍니다. 보통 이 애플리케이션의 버전을 명시합니다. 예를 들어서, mysql의 경우 5.7에서 8.0으로 버전이 중간에 릴리즈 되지 않고 한 번에 바뀌었는데, 실제 mysql을 검색하면 대부분 그 중간 버전은 존재하지 않는다. 일종의 관례처럼 지정하는 것으로 보이며 SemVer2가 강제되지 않아서 컨벤션이 제각기 다르다.
Chart.yaml에 들어가는 모든 필드들은 아래에 정리되어 있습니다.
apiVersion: 차트 API 버전 (필수)
name: 차트명 (필수)
version: SemVer 2 버전 (필수)
kubeVersion: 호환되는 쿠버네티스 버전의 SemVer 범위 (선택)
description: 이 프로젝트에 대한 간략한 설명 (선택)
type: 차트 타입 (선택)
keywords:
- 이 프로젝트에 대한 키워드 리스트 (선택)
home: 프로젝트 홈페이지의 URL (선택)
sources:
- 이 프로젝트의 소스코드 URL 리스트 (선택)
dependencies: # 차트 필요조건들의 리스트 (optional)
- name: 차트명 (nginx)
version: 차트의 버전 ("1.2.3")
repository: 저장소 URL ("https://example.com/charts") 또는 ("@repo-name")
condition: (선택) 차트들의 활성/비활성을 결정하는 boolean 값을 만드는 yaml 경로 (예시: subchart1.enabled)
tags: # (선택)
- 활성화 / 비활성을 함께하기 위해 차트들을 그룹화 할 수 있는 태그들
enabled: (선택) 차트가 로드될수 있는지 결정하는 boolean
import-values: # (선택)
- ImportValues 는 가져올 상위 키에 대한 소스 값의 맵핑을 보유한다. 각 항목은 문자열이거나 하위 / 상위 하위 목록 항목 쌍일 수 있다.
alias: (선택) 차트에 대한 별명으로 사용된다. 같은 차트를 여러번 추가해야할때 유용하다.
maintainers: # (선택)
- name: maintainer들의 이름 (각 maintainer마다 필수)
email: maintainer들의 email (각 maintainer마다 선택)
url: maintainer에 대한 URL (각 maintainer마다 선택)
icon: 아이콘으로 사용될 SVG나 PNG 이미지 URL (선택)
appVersion: 이 앱의 버전 (선택). SemVer인 필요는 없다.
deprecated: 차트의 deprecated 여부 (선택, boolean)
annotations:
example: 키로 매핑된 주석들의 리스트 (선택).
Chart.yaml에서 중요한 필드가 하나 더 있는데, dependencies 필드입니다. 이 필드에 필요한 차트들을 명시합니다. 데이터베이스를 서버 애플리케이션과 함께 사용하므로, 아래와 같이 작성할 수 있습니다.
dependencies:
- name: mysql
version: 9.14.3
repository: https://charts.bitnami.com/bitnami
- name: 차트 이름
- version: 차트 버전, APP VERSION이 아닙니다.
- repository: 차트 레포지터리 주소. https://artifacthub.io/ 아티팩트 허브에서 찾아볼 수 있습니다. 예를 들어 bitnami 레포지터리는 https://charts.bitnami.com/bitnami 입니다.
Deployment 상세 작성
모든 설정은 default로 생성된 helm 차트의 예시에서 일부만 수정한 것입니다.
Helm으로 설치되는 패키지의 오브젝트들은 template/ 디렉터리에서 values.yaml 파일과 함께 결합하여 생성됩니다. values와 결합된다는 의미는 helm으로 패키지를 설치할 때, 초기 설정 값을 오버라이드하여 DB 유저를 생성하는 등 커스텀하는 것을 의미합니다. values.yaml에 각 의존성 별로 오버라이드할 정보들 혹은, 쿠버네티스 리소스의 템플릿에 지정할 값들을 저장합니다.
템플릿은 쿠버네티스가 이해할 수 있는 yaml 파일 형태의 매니페스트를 생성하는 파일입니다. 헬름 템플릿 언어를 사용하여 동적으로 리소스 상세 값을 설정할 수 있습니다.
지금 설정에서 중요한 것은 Deployment 오브젝트 하나이므로, Deployment만 신경쓰면 됩니다. Helm에서 설정해야 할 부분은 image와 ports, env 필드입니다. template/deployment.yaml 파일을 확인합니다.
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.volumeMounts }}
volumeMounts:
{{- toYaml . | nindent 12 }}
{{- end }}
env:
- name: DB_HOST
value: {{ .Release.Name }}-mysql
- name: DB_PORT
value: "3306"
- name: DB_NAME
value: {{ .Values.mysql.auth.database }}
- name: DB_USERNAME
value: {{ .Values.mysql.auth.username }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-mysql
key: mysql-password
애플리케이션 개발을 위해 다음과 같이 템플릿을 정리했습니다. 기존 yaml 파일과는 상이한 모습을 보여주는데, go template 언어를 사용하여 이에 맞춰 쿠버네티스 리소스를 적용합니다. 예를 들어, 컨테이너의 이름을 지정할 때, {{ .Chart.Name }}
이렇게 값을 지정하는데 이 의미는 Chart.yaml 파일의 name 필드 값을 의미합니다. {{ .Values.* }}
도 유사한 맥락으로 values.yaml 파일에서 값을 가져옵니다.
values.yaml 작성
디플로이먼트에 사용할 이미지를 지정하기 위해 values.yaml을 먼저 정리하겠습니다.
image:
repository: nginx
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
금일을 기준으로 7 line 부터 이미지 필드가 있습니다. 여기서 원하는 레포지터리를 설정하고 policy, tag를 지정해줍니다. Chart.yaml 의 appVersion과 마찬가지로 따옴표로 감싸줍니다. values의 image로 deployment에 배포할 애플리케이션의 이미지를 설정할 수 있습니다.
그 다음으로는 서비스입니다.
service:
type: ClusterIP
port: 80
디플로이먼트를 노출시킬 서비스의 타입과 포트를 설정합니다.
service:
type: LoadBalancer
port: 3000
마지막으로 mysql을 설정했습니다.
# mysql configuration
mysql:
auth:
username: iseongtae
password: iseongtae1234
database: testdb
이 설정은 dependencies로 사용될 차트의 부모 차트 설정값으로, 자식 차트 설정을 덮어버립니다. 해당 설정은 dependencies로 설정했던 mysql의 values를 보며 커스텀할 부분을 찾아 바꿔준 것입니다. bitnami의 mysql 차트 values.yaml의 상단은 auth 내용이 존재합니다.
auth:
## @param auth.rootPassword Password for the `root` user. Ignored if existing secret is provided
## ref: https://github.com/bitnami/containers/tree/main/bitnami/mysql#setting-the-root-password-on-first-run
##
rootPassword: ""
## @param auth.createDatabase Whether to create the .Values.auth.database or not
## ref: https://github.com/bitnami/containers/tree/main/bitnami/mysql#creating-a-database-on-first-run
##
createDatabase: true
## @param auth.database Name for a custom database to create
## ref: https://github.com/bitnami/containers/tree/main/bitnami/mysql#creating-a-database-on-first-run
##
database: "my_database"
## @param auth.username Name for a custom user to create
## ref: https://github.com/bitnami/containers/tree/main/bitnami/mysql#creating-a-database-user-on-first-run
##
username: ""
## @param auth.password Password for the new user. Ignored if existing secret is provided
##
password: ""
사용할 차트 의존성을 찬찬히 읽어보고 오버라이드할 값을 정해주면 됩니다.
다시 Deployment로 돌아가겠습니다.
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
이미지와 포트는 values에서 설정대로 사용합니다.
그 다음 환경변수입니다.
env:
- name: DB_HOST
value: {{ .Release.Name }}-mysql
- name: DB_PORT
value: "3306"
- name: DB_NAME
value: {{ .Values.mysql.auth.database }}
- name: DB_USERNAME
value: {{ .Values.mysql.auth.username }}
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-mysql
key: mysql-password
애플리케이션에서 사용할 환경변수는 위와 같으며, 여기서 .Release는 차트 릴리즈 이름입니다. 차트를 설치하여 릴리즈 이름을 가지면 해당 릴리즈를 통해서 값을 동적으로 할당합니다. mysql 서비스를 호스트로 넣어줍니다. 또한, mysql의 시크릿을 password에서 가져옵니다.
애플리케이션 생성
서버 애플리케이션을 만들면 되는데, 저는 NestJS와 MikroORM을 사용하여 설정했습니다.
createMikroOrmOptions(): MikroOrmModuleOptions<IDatabaseDriver> {
return defineConfig({
entities: [__dirname + '/entity/**/*.entity{.ts,.js}'],
dbName: this.configService.get('DB_NAME'),
user: this.configService.get('DB_USERNAME'),
password: this.configService.get('DB_PASSWORD'),
host: this.configService.get('DB_HOST'),
port: this.configService.get('DB_PORT'),
driver: MySqlDriver,
debug: true,
});
}
커넥션을 얻고 확인하기 위한 과정이라 프레임워크와 라이브러리는 크게 중요하지 않습니다.
이 애플리케이션을 레지스트리로 배포한 다음, 레지스트리와 태그를 헬름 차트로 설정해줍니다.
image:
repository: 도커 허브/ECR 등등
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
helm dependency update
차트 설정이 모두 끝났다면 의존성을 업데이트 해줍니다. Chart.yaml 파일이 있는 디렉터리로 이동하여 명령어를 입력합니다.
helm dep up
로컬로 저장한 레포지터리를 모두 업데이트한 다음 의존성을 업데이트 해줍니다. 의존성을 업데이트하면, charts 디렉터리에 의존성으로 저장한 차트들이 추가됩니다.
$ tree
.
├── Chart.lock
├── Chart.yaml
├── charts
│ └── mysql-9.14.3.tgz
├── templates
│ ├── NOTES.txt
│ ├── _helpers.tpl
│ ├── deployment.yaml
│ ├── hpa.yaml
│ ├── ingress.yaml
│ ├── service.yaml
│ ├── serviceaccount.yaml
│ └── tests
│ └── test-connection.yaml
└── values.yaml
3 directories, 12 files
mysql-9.14.3.tgz가 의존성으로 등록한 mysql 차트입니다. 이제 패키지를 설치하면 끝입니다.
helm install nodejs .
# helm install 릴리즈 이름 디렉터리
시간이 지난 후 로그를 통해 데이터베이스와 연결되었는지 확인했을 때, 정상적으로 로그가 출력된 것을 확인할 수 있었습니다.
$ kubectl logs node-nodejs-server-c4fdbb4d8-27cqj
yarn run v1.22.19
$ node dist/src/main
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [NestFactory] Starting Nest application...
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] InfraModule dependencies initialized +8ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] DatabaseModule dependencies initialized +0ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] MikroOrmModule dependencies initialized +0ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] AppModule dependencies initialized +0ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[info] MikroORM version: 5.9.3
[discovery] ORM entity discovery started, using ReflectMetadataProvider
[discovery] - processing entity User
[discovery] - entity discovery finished, found 1 entities, took 6 ms
[info] MikroORM successfully connected to database testdb on mysql://iseongtae:*****@node-mysql:3306
[query] select 1 from information_schema.schemata where schema_name = 'testdb' [took 13 ms, 1 result]
[query] show variables like 'auto_increment_increment' [took 15 ms, 1 result]
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [InstanceLoader] MikroOrmCoreModule dependencies initialized +109ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [RoutesResolver] AppController {/}: +17ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [RouterExplorer] Mapped {/error, GET} route +1ms
[Nest] 33 - 11/12/2023, 4:53:16 AM LOG [NestApplication] Nest application successfully started +2ms
중간에 발생했던 문제점
중간에 리소스를 재시작하면서 데이터베이스 파드도 재시작한 적이 있습니다. 그런데 이상하게 Running 상태로 계속 파드가 머무르다가 CrushLoopBackOff 상태로 바뀌었습니다. 그래서 문제 상황을 확인하기 위해 describe로 확인했을 때, 이런 에러가 발생하는 것을 확인했습니다.
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 65s default-scheduler Successfully assigned default/mysql-0 to minikube
Normal Pulled 64s kubelet Container image "docker.io/bitnami/mysql:8.0.35-debian-11-r0" already present on machine
Normal Created 64s kubelet Created container mysql
Normal Started 64s kubelet Started container mysql
Warning Unhealthy 4s (x5 over 44s) kubelet Startup probe failed: mysqladmin: [Warning] Using a password on the command line interface can be insecure.
mysqladmin: connect to server at 'localhost' failed
error: 'Access denied for user 'root'@'localhost' (using password: YES)'
최초 startup probe에서 root 계정으로 접속이 되지 않는 문제였습니다.
helm으로 패키지를 재설치 해보고, values에서 rootPassword를 바꿔보고, 직접 접속하여 콘솔로 로그인을 시도해도 에러만 출력이 됐습니다. 반나절 동안 해결을 못했는데, mysql 파드의 로그를 보고 해답을 얻을 수 있었습니다.
INFO ==> Using persisted data
여기서 mysql 초기 비밀번호 생성 시 문제가 있을 수 있다는 가정 하에 bitnami chart 깃허브 레포지터리 이슈를 탐색했고 pvc가 문제가 된다는 점을 확인했습니다.
https://github.com/bitnami/charts/issues/4826
📌 2023.11.21
이때는 모르고 있었는데, 데이터베이스의 경우 각 데이터베이스 인스턴스마다 고유의 네트워크 ID와 동일한 스토리지를 소유하고 있어야 하므로 스테이트풀셋으로 생성되는 것이 일반적입니다. 여기서 스테이트풀셋은 각 파드 별로 동일한 스토리지를 소유하기에 각 파드 별 PVC를 가진 채 생성되며, 스케일다운 시에도 PVC는 남기고 파드를 삭제하게 됩니다. 따라서 이러한 동작은 잘 설계된 동작입니다.
문제 해결
PersistVolume이 왜 문제일까? 하고 리소스를 조회해보니, 헬름 차트로 생성했던 파드, 서비스, 시크릿은 삭제가 잘 됐지만, pv, pvc는 삭제가 되지 않았습니다. 그래서 persist volume의 데이터를 통해서 데이터베이스를 설정하였고, 여기서 문제가 발생했습니다.
그래서 에러 상황을 재현하여 루트 비밀번호가 적용되지 않는 문제를 확인하고자 했습니다.
$ kubectl get secret test-mysql -o yaml
apiVersion: v1
data:
mysql-password: aXNlb25ndGFlMTIzNA==
mysql-root-password: S25hV2VUQ1BBYQ==
kind: Secret
metadata:
annotations:
meta.helm.sh/release-name: test
meta.helm.sh/release-namespace: default
creationTimestamp: "2023-11-12T06:54:08Z"
labels:
app.kubernetes.io/instance: test
app.kubernetes.io/managed-by: Helm
app.kubernetes.io/name: mysql
app.kubernetes.io/version: 8.0.35
helm.sh/chart: mysql-9.14.3
name: test-mysql
namespace: default
resourceVersion: "365944"
uid: 5af74323-cad8-4b4f-a910-7c4c9090eef3
type: Opaque
이렇게 생성해둔 시크릿의 루트 비밀번호를 기록한 다음 패키지를 재설치 했습니다.
helm uninstall test
helm install test .
그 다음 exec로 컨테이너에 접속한 다음 mysql root 계정으로 접속을 시도했습니다. 여기서, pv 데이터로 설정을 적용하므로 최초로 발급받았던 루트 비밀번호를 디코딩한 다음 비밀번호로 입력했습니다.
반나절의 분노가 사그라지는 순간입니다.
이 문제를 해결하는 가장 간단한 방법은 pvc를 삭제한 다음 다시 배포하는 것입니다.
kubectl delete pvc <pvc명>
하지만, 근본적으로 해결하는 것이 아니라 일시적인 해결책일 뿐입니다. 따라서 pvc 설정과 secret을 동기화하기 위해 secret을 직접 생성하는 것이 좋다고 생각하여 secret도 함께 생성했습니다.
💡 Secret 생성
apiVersion: v1
kind: Secret
data:
mysql-password: {{ print .Values.mysql.auth.password | b64enc | quote }}
mysql-root-password: {{ print .Values.mysql.auth.rootPassword | b64enc | quote }}
metadata:
name: mysql-secret
labels:
{{- include "nodejs-server.labels" . | nindent 4 }}
namespace: default
type: Opaque
templates 디렉터리에 secrets.yaml 파일을 생성했습니다. 일반적으로 mysql 패키지에서 사용하는 시크릿의 설정을 복사하여 커스텀했습니다.
시크릿에서 사용할 유저 비밀번호, 루트 비밀번호는 values.yaml에서 사용하는 데이터를 사용합니다. 이때, secret은 기본적으로 base64로 인코딩된 데이터를 values.data의 값으로 가져가므로 base64로 인코딩해줘야 합니다.
mysql-password: {{ print .Values.mysql.auth.password | b64enc | quote }}
여기서 b64enc를 통해서 인코딩한 다음 quote로 따옴표로 감싸줍니다. secret이 설정이 유효한지 helm template로 확인할 수 있습니다.
이제 pvc를 삭제한 다음에 다시 패키지를 설치하면
$ kubectl get pods -w
NAME READY STATUS RESTARTS AGE
...
test-mysql-0 0/1 Running 0 21s
test-mysql-0 1/1 Running 0 21s
Database의 secret이 변하지 않으므로 항상 일정한 값을 유지합니다.
생각해보니까... 애초에 values.yaml에서 mysql의 auth.rootPassword 값을 입력해주면 해결될 문제인 것 같아서, rootPassword를 처음에 입력하니까 패키지를 같은 리소스 이름으로 재생성하더라도 에러가 발생하지 않았습니다. 위에서 Secret을 생성하는 과정도 원래 헬름이 생성해주는 Secret을 손수 직접 생성하는 것이므로 차이가 없습니다.
정리하며
꽤 단순한 문제고 해결 방법.. 이랄 것도 없는 해결이었는데, 문제를 넓게 보지 못한 것이 문제였다고 생각합니다. 하지만 그래도 깨달음은 얻었습니다.
- 헬름 차트 삭제는 pvc 까지 삭제하지 않는다(statefulset이거나 pvc를 직접 템플릿으로 생성하는 경우에 생성됨).
- pvc로 pv를 사용할 수 있게 되면, 데이터베이스 설정은 pv에서 설정을 적용한다.
그래도 문제는 해결해서 기분이 좋습니다.