[Kubernetes] 쿠버네티스에 데이터베이스 배포하고 애플리케이션 연결하기(Secret 사용)
현재 쿠버네티스 환경에 데이터베이스 패키지를 설치하였으며, 서버 애플리케이션의 띄웠습니다. 여기서 애플리케이션에서 데이터베이스 커넥션을 획득하기까지의 과정을 정리하고자 합니다. 환경변수의 설정부터 Secret 사용, 동적으로 데이터베이스 호스트 변수 설정, 데이터베이스 서비스 연결까지의 과정을 담았습니다.
매콤한 코멘트는 환영입니다. 👐
Helm으로 mysql 패키지 설치
Helm을 사용해서 빠르게 mysql 구동에 필요한 오브젝트를 한 번에 생성할 수 있습니다. 먼저 애플리케이션과 스토리지를 목적에 맞게 나누기 위해서 네임스페이스를 생성합니다.
kubectl create namespace db # 혹은 kubectl create ns db
데이터베이스 관련된 패키지는 모두 db 네임스페이스에 설치할 예정입니다. mysql을 설치하기 전에 어떠한 설정들이 있는지 먼저 확인했습니다.
$ helm show values bitnami/mysql
...
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: ""
## @param auth.replicationUser MySQL replication user
## ref: https://github.com/bitnami/containers/tree/main/bitnami/mysql#setting-up-a-replication-cluster
##
replicationUser: replicator
## @param auth.replicationPassword MySQL replication user password. Ignored if existing secret is provided
##
replicationPassword: ""
## @param auth.existingSecret Use existing secret for password details. The secret has to contain the keys `mysql-root-password`, `mysql-replication-password` and `mysql-password`
## NOTE: When it's set the auth.rootPassword, auth.password, auth.replicationPassword are ignored.
##
existingSecret: ""
Helm bitnami/mysql 패키지 값들 중에 auth 필드의 값을 확인하면 생성하면서 커스텀 유저를 같이 생성할 수 있다고 합니다. 그래서 패키지 설치와 함께 유저를 생성하고자 해당 값을 오버라이드 해줍니다.
root password는 새로운 secret과 함께 랜덤으로 생성되니 그대로 둬도 상관 없습니다.
auth:
username: "iseongtae"
password: "iseongtae1234"
database: "testdb"
참고로 헬름 공식문서에서는 차트 개발 팁과 비법이 있으므로 개발에 참고하시면 좋을 것 같습니다.
https://helm.sh/ko/docs/howto/charts_tips_and_tricks/
오버라이드할 값들을 모두 작성했으면 mysql을 헬름을 통해 설치해 줍니다.
$ helm install -f mysql-config.yaml mysql-nodejs-server bitnami/mysql -n db
NAME: mysql-nodejs-server
LAST DEPLOYED: Thu Nov 9 15:04:38 2023
NAMESPACE: db
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CHART NAME: mysql
CHART VERSION: 9.14.2
APP VERSION: 8.0.35
** Please be patient while the chart is being deployed **
Tip:
Watch the deployment status using the command: kubectl get pods -w --namespace db
Services:
echo Primary: mysql-nodejs-server.db.svc.cluster.local:3306
Execute the following to get the administrator credentials:
echo Username: root
MYSQL_ROOT_PASSWORD=$(kubectl get secret --namespace db mysql-nodejs-server -o jsonpath="{.data.mysql-root-password}" | base64 -d)
To connect to your database:
1. Run a pod that you can use as a client:
kubectl run mysql-nodejs-server-client --rm --tty -i --restart='Never' --image docker.io/bitnami/mysql:8.0.35-debian-11-r0 --namespace db --env MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD --command -- bash
2. To connect to primary service (read/write):
mysql -h mysql-nodejs-server.db.svc.cluster.local -uroot -p"$MYSQL_ROOT_PASSWORD"
헬름은 오브젝트의 모든 구성이 완료될 때까지 기다리지 않으므로 helm status로 확인하거나 kubectl get pods -w로 파드가 잘 생성되고 있는지 확인하는 것이 좋습니다. 혹은 --wait 옵션으로 생성되기까지 기다릴 수도 있습니다.
그럼 최초로 원했던 유저가 생성되었는지 확인해봅시다.
위처럼 오버라이드하여 생성하고자 했던 유저가 생성되어 있는 것을 확인할 수 있습니다. 이제 컨테이너에 웹서버를 띄운 다음 커넥션을 성공적으로 획득하는지 확인하면 끝입니다.
서버 애플리케이션 생성
서버 애플리케이션은 Node.js 환경의 NestJS 프레임워크를 사용한 백엔드 애플리케이션입니다. ORM은 MikroORM을 사용했습니다. TypeORM보다 번들 사이즈도 작고 OOP에 적합한 라이브러리인 것 같아서 택했습니다. 데이터베이스와 연결하는 부분은 다음과 같습니다. 여기서 configService 필드 레퍼런스가 환경변수를 가져옵니다.
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,
});
}
컨테이너를 사용해서 애플리케이션을 띄운다면, 도커파일에 직접 환경 변수를 넣거나, docker-compose 파일에 환경변수를 입력하거나, CI 환경에서 환경변수를 넣는 등의 방법으로 배포하게 될 것입니다. 여기서 쿠버네티스 환경을 사용하고 있으므로 오브젝트로서 환경변수를 사용해보는 것도 좋을 것 같아서 Secret이나 ConfigMap을 사용하려고 합니다.
Secret 생성
보통 민감한 정보들은 Secret을 사용하기 때문에 Secret에서 환경변수를 가져와 사용하도록 구성하도록 결정했습니다. 명령형으로 생성하거나 선언형으로 생성할 수 있는데, 저는 선언형을 택해서 생성했습니다. 바로 data 필드에 리스트로 값을 넣거나, 이미 만들어 둔 .env 파일에서 상세를 만들 수 있습니다.
DB_NAME="testdb"
DB_USERNAME="root"
DB_PASSWORD="1234"
DB_HOST="127.0.0.1"
DB_PORT="3306"
예를 들어 위와 같이 환경변수를 사용한다고 한다면, 아래 두 방법이 간단할 것 같습니다.
kubectl create secret generic test --dry-run=client --from-file=.env -o yaml >> test.yml
apiVersion: v1
kind: Secret
metadata:
name: nodejs-config
stringData:
DB_NAME: "testdb"
DB_USERNAME: "iseongtae"
DB_PASSWORD: "iseongtae1234"
DB_HOST: "127.0.0.1"
DB_PORT: "3306"
Secret의 상세는 base64로 인코딩된 데이터만 사용 가능하지만, 평문을 사용하고 싶다면 stringData에 필드에 정의하는 것도 방법입니다. stringData는 배포와 함께 base64로 인코딩됩니다. 이렇게 만든 환경변수를 쿠버네티스에 적용해주면 끝입니다.
kubectl apply -f test.yml
$ kubectl get secret
NAME TYPE DATA AGE
nodejs-config Opaque 5 26h
test Opaque 1 4m40s
서버 애플리케이션 배포
애플리케이션을 디플로이먼트로 배포하기 위해 아래와 같이 오브젝트 상세를 작성했습니다. 파드의 템플릿으로 환경변수를 시크릿으로 가져오도록 설정했습니다. 저는 시크릿의 이름을 nodejs-config로 설정해뒀습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: simple-api
spec:
replicas: 3
selector:
matchLabels:
app: simple-api
template:
metadata:
labels:
app: simple-api
spec:
containers:
- name: simple-api
image: 이미지
ports:
- containerPort: 3000
envFrom: # 환경변수를 가져오는 설정
- secretRef:
name: nodejs-config
---
apiVersion: v1
kind: Service
metadata:
name: simple-api
spec:
type: LoadBalancer
selector:
app: simple-api
ports:
- port: 3000
targetPort: 3000
이렇게 작성한 상세를 쿠버네티스에 적용하였습니다. 잘 적용됐는지 직접 로그로 확인했는데 다음과 같은 결과를 얻을 수 있었습니다.
$ kubectl logs pod/simple-api-8ff558b65-msb4d ✔ system minikube ⎈ 15:32:18
yarn run v1.22.19
$ node dist/src/main
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [NestFactory] Starting Nest application...
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] InfraModule dependencies initialized +9ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] DatabaseModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] MikroOrmModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] AppModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:32:09 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 5 ms
[info] MikroORM failed to connect to database testdb on mysql://iseongtae:*****@127.0.0.1:3306
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [InstanceLoader] MikroOrmCoreModule dependencies initialized +37ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [RoutesResolver] AppController {/}: +8ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [RouterExplorer] Mapped {/, GET} route +1ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [RouterExplorer] Mapped {/error, GET} route +0ms
[Nest] 33 - 11/10/2023, 6:32:09 AM LOG [NestApplication] Nest application successfully started +1ms
중간에 있는 ORM 로그를 보면 데이터베이스 연결이 실패했습니다. 그 이유는 파드 간 통신은 일반적으로 파드의 IP 주소 또는 Kubernetes 서비스의 DNS 주소를 사용하여 이루어지기 때문입니다. 다른 파드로의 연결을 수행할 때 파드의 IP 주소나 서비스의 DNS 주소를 사용해야 합니다. 그렇기 때문에 Service의 ClusterIP를 사용하도록 환경변수를 수정하려고 합니다.
Secret 수정
그래서 service의 ClusterIP를 조회하여 시크릿의 데이터로 값을 넣어줬습니다.
$ kubectl get svc -n db
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
mysql-nodejs-server ClusterIP 10.100.105.165 <none> 3306/TCP 24h
mysql-nodejs-server-headless ClusterIP None <none> 3306/TCP 24h
apiVersion: v1
kind: Secret
metadata:
name: nodejs-config
stringData:
DB_NAME: "testdb"
DB_USERNAME: "iseongtae"
DB_PASSWORD: "iseongtae1234"
DB_HOST: "10.100.105.165"
DB_PORT: "3306"
이렇게 직접 지정해줘도 되는데, 알아서 데이터를 넣어주고 배포해주면 편할 것 같습니다. 스크립트를 작성해줍니다.
#!/bin/bash
# Kubernetes 서비스의 IP 주소 가져오기
DB_HOST=$(kubectl get svc --namespace db mysql-nodejs-server -o jsonpath="{.spec.clusterIP}")
# sed 대체하고 Kubernetes에 적용하기
sed "s/DB_HOST_CHANGE/$DB_HOST/g" ./nodejs-config.yml | kubectl apply -f -
이렇게 서비스의 상세에서 clusterIP를 가져온 다음, 그 정보를 토대로 stream editor에서 값을 치환한 다음 쿠버네티스에 적용합니다.
이제 파드를 재시작 해줍니다. rollout을 사용하면 편하게 재시작할 수 있습니다.
kubectl rollout restart deploy simple-api
전체적으로 다시 환경변수를 가져오도록 하고, 로그를 확인해보면 정상적으로 연결이 되는 것을 확인할 수 있습니다.
$ kubectl logs simple-api-6d79cfc5b9-cxwb6
yarn run v1.22.19
$ node dist/src/main
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [NestFactory] Starting Nest application...
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] InfraModule dependencies initialized +155ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] DatabaseModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] MikroOrmModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] AppModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] ConfigHostModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] ConfigModule dependencies initialized +1ms
[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 8 ms
[info] MikroORM successfully connected to database testdb on mysql://iseongtae:*****@10.100.105.165:3306
[query] select 1 from information_schema.schemata where schema_name = 'testdb' [took 3 ms, 1 result]
[query] show variables like 'auto_increment_increment' [took 7 ms, 1 result]
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [InstanceLoader] MikroOrmCoreModule dependencies initialized +191ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [RoutesResolver] AppController {/}: +8ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [RouterExplorer] Mapped {/, GET} route +2ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [RouterExplorer] Mapped {/error, GET} route +0ms
[Nest] 33 - 11/10/2023, 6:56:16 AM LOG [NestApplication] Nest application successfully started +1ms
정리하며
쿠버네티스 내의 데이터베이스와 서버를, 연결하는 방법을 확인했습니다. 하지만, 여기서 들었던 의구심이 있습니다.
- 시크릿은 어차피 base64로 인코딩 되어 있으니까, 접근만 가능하다면 보인다.
- 어느 단계에서 Secret을 생성하면 좋을지 고민된다. -> 종종 레포지터리를 소스코드용과 빌드용으로 나누던데, 이런 개발/관리 단계를 구분하기 위해서 레포지터리를 구분하는 것 같다. 따라서, 빌드용 레포지터리로 CI에서 trigger가 발생하면 그 때, argoCD 등으로 작업하면 되지 않을까?
- Cluster IP가 중간에 바뀌는 상황은 없을까?
- 이게 Secret을 잘 사용하고 있는 것일까?
1. 시크릿 접근 권한에 관한 의구심
모두 스스로 해결해야 할 방향이라고 생각합니다. 먼저, 1번에 관한 의구심이 들어서 Secret을 확실하게 사용하는 방법을 살펴봤을 때, 공식문서에서 제공하는 4가지 방법이 있습니다.
- 시크릿에 대해 저장된 데이터 암호화(Encryption at Rest)를 활성화한다.
- 시크릿에 대한 최소한의 접근 권한을 지니도록 RBAC 규칙을 활성화 또는 구성한다.
- 특정 컨테이너에서만 시크릿에 접근하도록 한다.
- 외부 시크릿 저장소 제공 서비스를 사용하는 것을 고려한다.
위 내용은 쿠버네티스 시크릿에 관한 좋은 관행 에서 더 자세히 확인할 수 있습니다.
또한, 구글링하면서 확인한 다른 방법으로는 아래의 방법들이 있습니다.
- Sealed Secrets.
- External Secrets Operator.
- Secrets Store CSI driver.
그래서 위 방법을 실천하고 정리해보면 좋을 것 같습니다.
2. Cluster IP가 중간에 바뀌는 상황에 관한 의구심
ClusterIP는 서비스에서 사용하고 있으므로, 일반적으로 파드의 재생성과는 상관 없이 존재하므로 일반적으로 바뀌는 상황은 없습니다. 그렇지만, 서비스 자체가 재생성되는 경우에는 ClusterIP가 바뀔 수 있으므로, 서비스 상세에 직접 기재하는 방법을 사용할 수 있습니다.
3. Secret을 사용하는 최적의 상황인가?
일단 보안적인 상황에서 좋지 않다고 생각이 듭니다. 또한, 데이터에 직접 암호화를 할 수 있어서 데이터에서 값을 가져오는 등의 유연한 방법도 사용해보면 좋을 것 같습니다.
단순히 테스트용으로 구성했기 때문에, 다음에는 조금 더 복잡한 아키텍처 구조를 가정하고 생성해보면 좋을 것 같습니다. 또한, Secret을 최적으로 사용하는 방법들을 적용해서 실무에서도 사용할 수 있을 정도로 숙련도를 늘리고자 합니다. 특히 RBAC가 여러 오브젝트에 적용되는 구성이라서, RBAC에 익숙해지는 것도 시도할 예정입니다.
다음에는 Secret을 위한 관행과 정말 Secret하게 사용하는 방법에 대해 기재하겠습니다. :)
참고
https://helm.sh/ko/docs/howto/charts_tips_and_tricks/
https://kubernetes.io/ko/docs/concepts/security/secrets-good-practices/
https://auth0.com/blog/kubernetes-secrets-management/