2025년 9월 21일 일요일

IP 하나로 k8s 클러스터 내 여러 개 서비스에 접근하기

* 이 글은 백엔드 개발자가 홈서버를 구성하며 겪은 시행착오를 공부하고 정리한 글입니다. 잘못 알고있는 점이 있다면 지적해주시면 감사하겠습니다.


이 글에서는 다음 항목을 다룹니다.

  • MetalLB 설치
  • LoadBalnacer 구성
  • ClusterIP 구성
  • NGINX ingress controller 구성


다이어그램으로 나타내면 다음과 같습니다.


앞선 포스트에서 물리 서버 위에 RKE2로 k8s 클러스터를 구성한 후 MySQL을 설치했습니다. 이제 외부에서 접근할 수 있도록 만들어보겠습니다.


Install MetalLB

https://metallb.universe.tf/installation/

MetalLB는 베어 메탈 머신에서 로드밸런서 구현을 제공하는 오픈소스 프로젝트입니다.


Helm을 사용하여 MetalLB를 설치하도록 하겠습니다.

우선 가이드에 따라 MetalLB를 설치할 namespace에 권한을 주어야 합니다. metallb-system 네임스페이스에 설치하도록 하겠습니다.

https://metallb.universe.tf/installation/#installation-with-helm


권한 설정 파일을 생성합니다.

vi metallb-namespace.yaml


다음 내용을 붙여넣습니다.

apiVersion: v1
kind: Namespace
metadata:
  name: metallb-system
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged


저장한 뒤에 privilege 설정을 적용합니다.

kubectl apply -f metallb-namespace.yaml


이제 Helm으로 MetalLB를 metallb-system 네임스페이스에 설치합니다.

helm repo add metallb https://metallb.github.io/metallb
helm repo update


helm install metallb metallb/metallb --namespace metallb-system --create-namespace

MetalLB 설정 파일을 만들어줍니다.

vi metallb-config.yaml


다음 내용을 붙여넣습니다.

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: metallb-ip-pool
  namespace: metallb-system
spec:
  addresses:
    - {ip-pool-start}-{ip-pool-end}
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: my-l2-advertisement
  namespace: metallb-system

- spec.addresses.... - IP pool을 설정해주는 부분입니다. 사용 가능한 IP pool이 있으시면 해당 IP pool을 등록해주셔도 되고, 없다면 현재 노드의 IP로 등록해주어도 동작합니다. 저는 한 대의 노드로 구성하였으므로 {ip-pool-start}, {ip-pool-end} 둘다 현재 노드의 IP로 설정해주겠습니다.

불가피하게 하나의 IP만 등록할 경우 주의해야할 점이 몇 가지 있습니다.

  • 하나의 LoadBalancer만 생성 가능합니다.
  • 해당 IP의 노드가 실패할 경우 LoadBalancer를 통한 접근이 불가능해집니다.
  • 개방할 port가 겹치지 않아야 합니다.


config 파일을 적용합니다.

kubectl apply -f metallb-config.yaml


Install NGINX

LoadBalnacer에서 원하는 서비스로 트래픽을 전달할 수 있는데, NGINX를 추가로 설치해주는 이유가 궁금할 수 있습니다. 그 이유는 환경적 제약사항을 우회하기 위해서입니다.

저는 하나의 IP만 가용한 환경에서 k8s 클러스터를 구성하고 있습니다. LoadBalancer 서비스는 포트를 분리하더라도 여러 개의 서비스로 트래픽을 보낼 수 없기 때문에, NGINX ingress controller에서 여러 개의 서비스로 트래픽을 분배하는 역할을 수행하도록 구상했습니다.


helm으로 nginx ingress를 설치하기 위해 repo에 ingress-nginx를 추가합니다.

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update


31000 port로 MySQL primary pod에 접근하고, 32000 port로 MySQL secondary pods에 접근하도록 하겠습니다.

helm-ingress-values.yaml 파일을 생성하고, 아래 내용을 붙여넣은 후 저장합니다.

tcp:
  31000: "default/mysql-primary:3306"
  32000: "default/mysql-secondary:3306"

controller:
  service:
    extraPorts:
      - name: mysql-primary
        port: 31000
        targetPort: 31000
        protocol: TCP
      - name: mysql-secondary
        port: 32000
        targetPort: 32000
        protocol: TCP

helm-ingress-values.yaml 파일을 사용하여 커스텀한 nginx ingress controller를 설치합니다.

helm install nginx-ingress ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  -f helm-ingress-values.yaml

정상적으로 설치되었다면 아래와 같이 안내 문구가 나타납니다.



주의

저와 같이 RKE2로 k8s를 설치하셨다면 다음과 같은 오류 문구를 마주할 수 있습니다.


이미 RKE2에서 자동적으로 구성한 nginx ingress가 존재하기 때문에 발생하는 오류입니다.
kubectl get all -n kube-system | grep ingress



저는 기존에 존재하는 NGINX ingress를 제거해주도록 하겠습니다.


/etc/rancher/rke2/config.yaml 파일을 생성해줍니다.

sudo vi /etc/rancher/rke2/config.yaml

아래 내용을 붙여넣고 저장합니다.
disable:
  - rke2-ingress-nginx
RKE2를 재시작합니다.
sudo systemctl restart rke2-server

이제 RKE2 nginx ingress가 존재하지 않는 것을 확인할 수 있습니다.
kubectl get all -n kube-system | grep ingress



ClusterIP 생성

MySQL primary, secondary pod에 접근하기 위해 ClusterIP를 생성하겠습니다.

* 앞선 포스트에서 안내한 방식대로, helm + bitnami 을 통해 설치할 경우 자동적으로 생성되기 때문에 생략할 수 있습니다.


아래 두 파일을 생성합니다.
mysql-primary-cluster-ip.yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql-primary
spec:
  type: ClusterIP
  ports:
    - port: 3306
      targetPort: 3306
      protocol: TCP
      name: mysql
  selector:
    statefulset.kubernetes.io/pod-name: mysql-primary-0
mysql-secondary-cluster-ip.yaml
apiVersion: v1
kind: Service
metadata:
  name: mysql-secondary
spec:
  type: ClusterIP
  ports:
    - port: 3306
      targetPort: 3306
      protocol: TCP
      name: mysql
  selector:
    app.kubernetes.io/instance: mysql
    app.kubernetes.io/component: secondary

테스트

이제 외부에서 접근할 수 있습니다.
실제로 DB에 연결한 후 hostname을 출력해보면 접근 포트에 따라 다른 hostname을 출력하는 것이 확인됩니다.

hostname: mysql-primary-0
hostname: mysql-secondary-0



2025년 7월 5일 토요일

k8s cluster 구축 - RKE2, Longhorn, MySQL replication

* 이 글은 백엔드 개발자가 홈서버를 운영하며 공부하고 정리한 글입니다. 잘못 알고있는 점이 있다면 지적해주시면 감사하겠습니다.


이 글에서는 다음 항목을 다룹니다.

  • RKE2로 k8s 환경 구성
  • Longhorn 설치
  • Longhorn 기본 설정을 오버라이드하는 StorageClass 구성
  • MySQL 설치
  • MySQL replicaiton 구성
  • MetalLB 설치
  • LoadBalnacer 구성


다이어그램으로 나타내면 다음과 같습니다.


Install RKE2

https://docs.rke2.io/install/quickstart

RKE2는 Rancher에서 개발한 k8s distro입니다. 가이드를 참고하여 RKE2를 설치합니다.

curl -sfL https://get.rke2.io | sudo sh -


설치가 완료되면 rke2-server.service를 enable 및 start 합니다.
start는 수 분의 시간이 소요될 수 있습니다.

sudo systemctl enable rke2-server.service
sudo systemctl start rke2-server.service


잘 설치되었는지 확인해봅니다.

systemctl status rke2-server.service

rke2-server.service가 enabled 및 active되었음을 확인할 수 있습니다.


이제 k8s 클러스터에 접근하기 위해 config 파일을 복사합니다.

mkdir -p ~/.kube
sudo cp /etc/rancher/rke2/rke2.yaml ~/.kube/config


해당 파일의 권한을 변경하여 sudo 없이 kubectl 커맨드를 실행할 수 있도록 합니다.

sudo chown $(id -u):$(id -g) ~/.kube/config


이제 shell에 RKE2 kubectl이 설치된 경로를 추가해주어야 합니다.

사용하는 shell 설정 파일을 열어줍니다. 저는 zsh를 사용하고 있으므로, ~/.zshrc 파일을 수정하겠습니다.

vi ~/.zshrc


RKE2 kubectl이 설치된 경로를 추가해줍니다.

export PATH=$PATH:/var/lib/rancher/rke2/bin


변경한 내용을 현재 shell에 반영합니다.

source ~/.zshrc


kubectl이 정상 동작하는지 확인해봅니다.

kubectl version


Install Helm

https://helm.sh/docs/intro/install/#from-apt-debianubuntu

Longhorn, MySQL을 Helm을 통해 설치할 예정이므로, 아직 Helm을 설치하지 않았다면 가이드를 참고하여 진행합니다.

curl https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null

sudo apt-get install apt-transport-https --yes

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list

sudo apt-get update
sudo apt-get install helm


k8s storage

k8s에서 스토리지를 사용하는 방법은 크게 세 가지가 있습니다.

  1. PV/PVC를 직접 프로비저닝한다.
  2. NAS를 사용한다.
  3. 분산 스토리지를 사용한다.


1. PV/PVC를 직접 프로비저닝하는 경우

https://kubernetes.io/docs/concepts/storage/persistent-volumes/#introduction


PV/PVC를 직접 프로비저닝해서 사용할 때 가장 큰 문제로 느꼈던 점은, 노드 지역성을 보장해야 한다는 점이었습니다. 특정 노드에 PV를 프로비저닝하면, 해당 PV에 접근하기 위해서 파드가 해당 노드 위에 올라가야만 했습니다.

예를 들어 다음 상황을 생각해보겠습니다.

  1. PV 생성 -> Node A
  2. MySQL Pod 스케쥴링 -> Node A
  3. MySQL Pod 재배포
  4. MySQL Pod 스케쥴링 -> Node B

하지만 Node B에는 PV가 존재하지 않으므로, MySQL 파드는 실패하게 됩니다.


해결법으로는 node affinity를 주는 방법이 있습니다. 예를 들어 host 네임으로 노드를 구별하여 PV 및 파드에 할당되는 노드를 제한하는 방법입니다. 하지만 node affinity를 통한 해결책은 특별한 노드가 생긴다는 점에서 k8s의 철학과 맞지 않는다고 느꼈습니다.


2. NAS를 사용하는 경우

NAS(Network Accessed Storage)는 네트워크를 통해 접근하는 스토리지를 말합니다. 따라서 NAS 안에서도 클라우드에서 제공하는 관리형 서비스, 혹은 다른 베어 메탈 머신에 설치한 MySQL 애플리케이션 등 여러 가지 종류가 있을 수 있습니다. 주어진 k8s 클러스터 외 환경에 스토리지를 운영한다는 점이 핵심입니다.

NAS를 사용하면 인프라 아키텍쳐적으로 깔끔하고, 고가용성을 고려하여 설계하기 용이해보여서 가장 마음이 가는 선택지입니다. 언젠가 구성해보고 싶지만 이번에 선택하지 않은 문제는 비용 때문입니다.

  1. 클라우드 서비스를 사용할 경우: 클라우드 사용료가 발생합니다.
  2. 다른 베어 메탈 서버 위 k8s에 구성: 추가 서버 구매 비용 및 전기세가 발생합니다.
  3. 현재 베어 메탈 서버 위 k8s 외 방식으로 구성: k8s 위에 MySQL을 올린다는 목적과 부합하지 않습니다.


3. 분산 스토리지를 사용하는 경우

분산 스토리지는 동일한 데이터의 카피를 여러 노드에서 저장하는 기술입니다. 따라서 데이터 유실 가능성이 적고, 하나의 노드 실패 시 레플리케이트된 다른 노드의 데이터에 접근함으로써 고가용성을 이룰 수 있습니다.

하지만 제가 분산 스토리지를 선택한 이유는 앞서 말한 장점보다는 다른 부분이 더 컸습니다. 바로 노드 지역성의 해소입니다. 분산 스토리지 서비스가 파드로 하여금 실제로 데이터가 저장된 위치와 무관하게 접근할 수 있도록 지원해주므로, PV/PVC를 직접 프로비저닝할 경우의 단점으로 지적한 노드 지역성을 해소할 수 있습니다.


Install Longhorn

https://longhorn.io/

Longhorn은 Rancher에서 개발한 분산 스토리지입니다. 설정이 간편하기도 하고, RKE2와 개발사가 같기도 하므로 호환성 걱정도 덜 수 있어 선택했습니다.


https://longhorn.io/docs/1.9.0/deploy/install/install-with-helm/

Helm을 이용해 Longhorn 1.0.0 버전을 longhorn-system 네임스페이스에 설치하겠습니다.

helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn --namespace longhorn-system --create-namespace --version 1.9.0


Longhorn이 제대로 설치되었는지 다음 커맨드로 확인할 수 있습니다.

kubectl -n longhorn-system get pod
kubectl -n longhorn-system get svc


앞서 말씀드렸다시피 Longhorn을 가용성을 확보하기 위해 분산 스토리지로서 활용하기보다는, 데이터의 노드 지역성을 해결하기 위해 사용하는 것이 목적입니다. 그렇기 때문에 replica=1 설정이 필요합니다.


Longhorn replica 개수를 설정하는 방법은 세 가지가 있습니다.

  1. Longhorn storage의 default 설정을 수정한다.
  2. Longhorn storage를 사용하는 service에서 replica를 직접 지정한다.
  3. 커스텀 Longhorn storage를 생성한다.


1. Longhorn storage의 default 설정을 수정한다.

Longhorn storage의 default replica 개수를 1로 설정하는 방법은 간단하지만 몇 가지 단점을 가집니다.

  1. 기본값을 변경했다는 사실을 잊으면, 기본값대로 3개의 replica를 예상하고 운용하다가 의도치 않은 데이터 유실을 마주할 수 있습니다.
  2. 현재 MySQL 서비스에 대해서는 1개의 replica만 유지하고자 하지만, 다른 서비스의 경우 복수의 replica를 사용하고 싶을 수 있습니다. 이 경우에는 결국 default replica 개수 변경만으로는 해결할 수 없습니다.


2. Longhorn storage를 사용하는 service에서 replica를 직접 지정한다.

이것 역시 간단한 방법입니다. 예를 들어, MySQL service를 설치할 때 replica 수를 지정하는 방식입니다. 하지만 매번 서비스를 추가할 때마다 replica 수를 지정해주는 건 통일성이 부족하다고 느껴집니다. 하드코딩을 기피하는 것과 유사하게 손이 가지 않는 선택지입니다.


3. 커스텀 Longhorn storage class를 생성한다.

저는 커스텀 Longhorn storage class를 생성했습니다. 기본값을 바꾸지 않기 때문에 안전하고, 한 개의 replica 설정이 필요한 서비스들이 범용적으로 사용할 수 있기 때문입니다.


커스텀 Longhorn storage class 설정 파일을 longhorn-1-replica-storage-class.yaml 로 생성합니다.

vi longhorn-1-replica-storage-class.yaml


다음 내용을 붙여넣습니다.

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: longhorn-1-replica
provisioner: driver.longhorn.io
allowVolumeExpansion: true
parameters:
  numberOfReplicas: "1"
reclaimPolicy: Retain

주요 파라미터에 대한 설명은 다음과 같습니다.

  • provisioner: driver.longhorn.io - Longhorn으로 프로비저닝합니다.
  • allowVolumeExpansion: true - 용량을 확장할 수 있도록 합니다.
  • parameters. numberOfReplicas: "1" - replica를 1로 설정합니다.
  • reclaimPolicy: Retain - storage를 삭제해도 pv를 자동으로 삭제하지 않습니다. 혹여 실수로 삭제하더라도 복구할 수 있습니다.


StorageClass를 생성합니다.

kubectl apply -f longhorn-1-replica-storage-class.yaml


StorageClass가 잘 생성되었는지 확인합니다.

kubectl describe sc longhorn-1-replica


Install MySQL

https://github.com/bitnami/charts/tree/main/bitnami/mysql

Helm 및 Bitnami로 MySQL을 설치하고, replication 설정을 해주겠습니다.


Replication의 목적

Replication하는 이유는 다음과 같습니다.

  • 데이터 유실 가능성을 줄입니다.
  • replica DB를 read-only DB로 활용하여 primary DB의 부하를 분산시킵니다.


replication 설정을 위해서 파라미터를 직접 입력하는 것도 가능합니다. 하지만 저는 파라미터 직접 입력은 1. 실수할 여지가 크고 2. 재수행이 어렵다는 단점 때문에 기피하는 편입니다.

대신 Helm values file을 활용하는 것을 선호합니다. 미리 정의한 yaml 파일에 파라미터를 입력하고, helm install/upgrade 시 전달하는 방식입니다.

https://helm.sh/docs/chart_template_guide/values_files/

Helm values file을 사용하여 MySQL을 설치하기 위해, 우선 helm-mysql-values.yaml 파일을 생성합니다.

vi helm-mysql-values.yaml

다음 내용을 붙여넣습니다.

image:
  repository: bitnami/mysql
  digest: sha256:7089d796fc9b4629a628bd445e4afabe607351ee665444c3197bdeaed812ea65


architecture: replication


auth:
  rootPassword: "{root-password}"
  username: "{username}"
  password: "{password}"
  replicationPassword: "{replication-password}"


global:
  storageClass: "longhorn-1-replica"


primary:
  persistence:
    size: 50Gi
  resources:
    requests:
      memory: "2Gi"
      cpu: "2"
    limits:
      memory: "2Gi"
      cpu: "2"


secondary:
  replicaCount: 1
  persistence:
    size: 50Gi
  resources:
    requests:
      memory: "1Gi"
      cpu: "1"
    limits:
      memory: "1Gi"
      cpu: "1"

주요 파라미터에 대한 설명은 다음과 같습니다.


- image.digest: ... - 위 예제의 digest는 MySQL 8.4.5 버전의 SHA256 digest입니다. tag로 지정하지 않은 이유는 동일 태그를 재사용하여 태그가 같더라도 실제로는 다른 이미지인 경우가 존재하기 때문입니다.

이는 image.tag로 지정할 경우 표시되는 메시지에서 경고하는 내용이기도 합니다.


따라서 동일 이미지를 보장하기 위해 digest로 지정해주도록 합니다.

digest는 이미지를 docker pull할 때 출력됩니다.

docker pull bitnami/mysql:{version}
# docker pull bitnami/mysql:8.4.5


이미 pull한 이미지라면 docker inspect 커맨드로 확인할 수 있습니다.

docker inspect bitnami/mysql:{version} --format='{{index .RepoDigests 0}}'
# docker inspect bitnami/mysql:8.4.5 --format='{{index .RepoDigests 0}}'

하지만 digest는 사람이 읽을 수 없는 값이기 때문에, yaml 파일만 봐서는 어떤 버전을 사용하는지 알 수 없습니다. 이 문제를 해결하기 위해 immutable tag를 사용하는 방법도 존재합니다.

https://techdocs.broadcom.com/us/en/vmware-tanzu/application-catalog/tanzu-application-catalog/services/tac-doc/apps-tutorials-understand-rolling-tags-containers-index.html

설정한 digest와 동일한 이미지를 가리키는 immutable tag는 bitnami/mysql:8.4.5-debian-12-r0 입니다.


digest 대신 immutable tag를 사용하고자 한다면, digest 라인을 다음과 같이 수정해줍니다.

  # digest: sha256:7089d796fc9b4629a628bd445e4afabe607351ee665444c3197bdeaed812ea65
  tag: 8.4.5-debian-12-r0


architecture: replication - source-replica 아키텍처를 지정해주는 핵심 설정입니다.

- auth: ... - source, replica의 유저/비밀번호를 설정합니다.

- global.storageClass: "longhorn-1-replica" - 앞서 생성한 커스텀 storage class로 설정함으로써 하나의 replica만 가지도록 합니다. 주의해야할 부분은, 여기서 말하는 replica는 Longhorn에 의한 볼륨 레벨 replica로, DB 레벨 replica와 별개라는 점입니다.

- primary.resources.... - k8s에서 얼마만큼의 리소스를 할당할 것인지 지정합니다.

resource를 지정하지 않을 경우 다음과 같은 경고 문구가 출력됩니다.


이때 주목할 점은, requests와 limits 값을 동일하게 유지했다는 점입니다. 이는 QoS(Quality of Service)를 고려한 선택입니다.

https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/#quality-of-service-classes


k8s는 파드를 요청한 리소스의 requests/limits에 따라 세 가지 QoS class로 구분합니다.

  • Guaranteed
  • Burstable
  • BestEffort


Guaranteed는 해당 제약 조건을 초과하지 않는 한 pod가 kill되지 않는 것을 보장합니다. 그 외의 QoS(Burstable/BestEffort)는 클러스터 리소스 상황에 따라 evict될 수 있습니다.

https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/#guaranteed


따라서 MySQL과 같이 중요한 파드를 k8s가 kill할 수 없도록 하기 위해 Guaranteed로 만들어주는 것이 좋습니다. Guaranteed가 되기 위해서는 CPU 및 memory의 requests와 limits가 동일해야 하므로 그에 맞춰 설정했습니다. 물론 primary와 secondary의 requests와 limits를 통일시킬 필요는 없습니다.


- secondary.replicaCount: DB 레벨 replica의 개수를 설정합니다. 이 역시 Longhorn에 의한 볼륨 레벨 replica와 무관합니다.


이제 준비한 설정 파일로 MySQL을 설치합니다.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

helm install mysql bitnami/mysql -f helm-mysql-values.yaml

APP VERSION: 9.3.0 이라는 문구 때문에 의도하지 않은 버전을 설치한 것으로 오해할 수 있지만, 해당 정보는 단순히 Helm chart의 메타데이터로, 실제로는 8.4.5 버전이 설치된 것이 맞습니다. 설치된 MySQL 버전은 뒤에서 확인해보겠습니다.


pod를 출력해보면 MySQL primary/replica pod를 확인할 수 있습니다.

kubectl get po


이제 MySQL에 접속해서 하고싶은 게 많습니다. 스키마도 만들고, 테이블도 만들고, 데이터도 추가하고 싶습니다. 그러기 위해서는 MySQL pod 안에 들어가도 되지만, DataGrip이나 MySQL Workbench 같은 DB 툴로 편하게 수정하고 싶습니다. 또, 어플리케이션에서 접근할 때 k8s CoreDNS를 통해 접근할 수도 있지만, 언젠가 MySQL을 k8s 클러스터 밖으로 옮기는 상황을 고민하다 보면 CoreDNS보다는 네트워크 기반으로 접근하고 싶다는 생각이 자꾸 듭니다. 또한, CoreDNS를 통해서만 접근 가능하다면, 로컬에서 개발할 때 MySQL에 접근할 수 없다는 치명적인 단점을 가지기도 합니다.

그러므로 외부에서 접근할 수 있도록 열어주도록 합니다. 여기에도 여러가지 방법이 있습니다.

  1. Port forwarding
  2. NodePort
  3. LoadBalancer


1. Port forwarding

간단하게 테스트할 때 좋은 방법이지만, 불안정한 방법입니다. session 유지가 필요하고, session 유지 제약을 회피하기 위해 background에서 실행시키더라도 pod가 내려갔다가 다시 올라오면 접속이 끊깁니다.

2. NodePort

하나의 노드만 운영한다면 특별한 단점은 없습니다. 하지만 특정 노드에만 접근하는 방식이므로 확장성이 부족하여 노드 추가 시 대응하기 어렵습니다. 예를 들어, 두 대의 노드가 존재할 때, 원하는 파드에 접근하기 위해서는 어떤 노드에 접근해야 하는지 어떻게 알 수 있을까요? 사람이야 직접 파드가 올라간 노드를 확인할 수 있지만, 어플리케이션에서는 어떻게 확인할 수 있을까요?

3. LoadBalancer

노드를 추가하는 경우를 고려하여 LoadBalancer를 선택했습니다. 이 선택은 저의 개인적인 경험과도 관계가 있습니다. 이전에 한 대의 Raspberry Pi 4B로 k8s 클러스터를 운영하다가, 메모리가 부족하여 노드를 하나 더 추가했었고, 그때 NodePort에서 LoadBalancer로 전환해야 했던 기억이 있기 때문입니다.


LoadBalancer Provisioning

LoadBalancer를 통해 MySQL primary DB에 접속할 수 있도록 설정하겠습니다.

바로 LoadBalancer를 설치할 수 있다면 좋겠지만, Bare metal 머신에서는 LoadBalancer를 설정하기 위해 신경써야할 부분이 있습니다.
우선 그냥 설치할 경우 어떤 문제가 발생하는지 보여드리겠습니다. 


MySQL primary DB 파드에 접근하기 위한 LoadBalancer 설정 파일을 생성합니다.

vi mysql-primary-lb.yaml


다음 내용을 붙여넣습니다.

apiVersion: v1
kind: Service
metadata:
  name: mysql-primary-lb
spec:
  selector:
    app.kubernetes.io/instance: mysql
    app.kubernetes.io/component: primary
  ports:
    - protocol: TCP
      port: 3306
      targetPort: 3306
  type: LoadBalancer

  • port - 외부에서 접속하는 포트입니다. 원하는 값으로 설정합니다.
  • targetPort - 컨테이너 포트입니다. MySQL 포트를 변경하지 않았으므로 기본값인 3306으로 설정합니다.

LoadBalancer를 생성합니다.
kubectl apply -f mysql-primary-lb.yaml


service를 확인해봅시다.

kubectl get services

mysql-primary-lb 서비스의 EXTERNAL-IP이 <pending>으로 표시되는 것을 확인할 수 있습니다. IP pool이 존재하지 않기 때문입니다. 이를 해결하기 위해 MetalLB를 사용합니다.


Install MetalLB

https://metallb.universe.tf/installation/

MetalLB는 베어 메탈 머신에서 로드밸런서 구현을 제공하는 오픈소스 프로젝트입니다.


Helm을 사용하여 MetalLB를 설치하도록 하겠습니다.

우선 가이드에 따라 MetalLB를 설치할 namespace에 권한을 주어야 합니다. metallb-system 네임스페이스에 설치하도록 하겠습니다.

https://metallb.universe.tf/installation/#installation-with-helm


권한 설정 파일을 생성합니다.

vi metallb-namespace.yaml


다음 내용을 붙여넣습니다.

apiVersion: v1
kind: Namespace
metadata:
  name: metallb-system
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged


저장한 뒤에 privilege 설정을 적용합니다.

kubectl apply -f metallb-namespace.yaml


이제 Helm으로 MetalLB를 metallb-system 네임스페이스에 설치합니다.

helm repo add metallb https://metallb.github.io/metallb
helm repo update


helm install metallb metallb/metallb --namespace metallb-system --create-namespace

MetalLB 설정 파일을 만들어줍니다.

vi metallb-config.yaml


다음 내용을 붙여넣습니다.

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: metallb-ip-pool
  namespace: metallb-system
spec:
  addresses:
    - {ip-pool-start}-{ip-pool-end}
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: my-l2-advertisement
  namespace: metallb-system

- spec.addresses.... - IP pool을 설정해주는 부분입니다. 사용 가능한 IP pool이 있으시면 해당 IP pool을 등록해주셔도 되고, 없다면 현재 노드의 IP로 등록해주어도 동작합니다. 저는 {ip-pool-start}, {ip-pool-end} 둘다 현재 노드의 IP로 설정해주겠습니다.

불가피하게 하나의 IP만 등록할 경우 주의해야할 점이 몇 가지 있습니다.

  • 하나의 LoadBalancer만 생성 가능합니다.
  • 해당 IP의 노드가 실패할 경우 LoadBalancer를 통한 접근이 불가능해집니다.
  • 개방할 port가 겹치지 않아야 합니다.


config 파일을 적용합니다.

kubectl apply -f metallb-config.yaml


이제 서비스를 다시 확인해보면, EXTERNAL-IP가 <pending>이 아닌 현재 노드의 IP로 설정된 것을 확인할 수 있습니다.

kubectl get services

긴 여정 끝에 드디어 외부에서 MySQL에 접속할 수 있게 되었습니다.

DataGrip에서 root 계정으로 접속해보겠습니다.

helm-mysql-values.yaml 파일에서 설정한 rootPassword로 정상적으로 접속되었습니다.


덤으로, 앞서 MySQL 설치 시 나오는 APP VERSION: 9.3.0 문구에 대해 설명드린 내용을 증명할 수 있는 시간이 왔습니다.

APP VERSION: 9.3.0 이라는 문구 때문에 의도하지 않은 버전을 설치한 것으로 오해할 수 있지만, 해당 정보는 단순히 Helm chart의 메타데이터로, 실제로는 8.4.5 버전이 설치된 것이 맞습니다.

SELECT VERSION();

말씀드린대로 8.4.5 버전으로 설치되어 있습니다.


남은 단계는 일반적인 MySQL 설정과 동일합니다.

앞으로 root 대신 다른 계정(ariadacapo)으로 DDL을 수행하기 위해 권한을 주겠습니다.

GRANT ALL PRIVILEGES ON *.* TO '{username}'@'%';
FLUSH PRIVILEGES;

이제 해당 계정으로 접속하면 스키마 생성, 테이블 생성 등 원하는 작업을 원하는 만큼 수행할 수 있습니다.


ariadacapo 계정으로 다시 접속하겠습니다.

제대로 권한을 받았는지 시험 삼아 hello_world 스키마를 생성해보겠습니다.

create schema hello_world;

정상적으로 생성되었습니다.


이제 외부에서 접속 가능한 MySQL이 준비되었으므로, 마음껏 활용할 수 있습니다.

단, 현재 구성에서는 MySQL primary pod에만 접근할 수 있습니다. secondary pod에도 접근할 수 있도록 구성하는 내용은 다음 포스트에서 진행하도록 하겠습니다.


2025년 6월 28일 토요일

SSH fingerprint 확인 및 삭제

SSH fingerprint는 리모트 SSH 공개키의 hash입니다. 리모트에 SSH 접속하면 해당 리모트의 SSH fingerprint를 저장하게 되고, 이후 접속마다 hash를 비교하여 의도치 않은 리모트에 접속(man in the middle attack)하는 것을 막아줍니다.

따라서 리모트의 SSH 키가 변경되면 접속 요청 시 다음과 같은 오류가 발생하기 때문에, 클라이언트에 저장된 fingerprint를 제거해주어야 합니다.


저장된 fingerprint 리스트를 확인하기 위해 다음 커맨드를 실행합니다.

vi ~/.ssh/known_hosts

원하는 host의 fingerprint를 삭제한 후 저장합니다.

다시 SSH 접속을 시도하면, 최초 접속과 동일하게 fingerprint를 저장한 후 접속할 수 있습니다.


scp를 사용한 로컬-리모트 간 복사

scp를 사용하여 리모트에서 로컬로, 로컬에서 리모트로 파일을 전송하는 방법을 알아봅니다.
secure copy라는 이름대로 cp와 동일한 감각으로 사용할 수 있습니다.

참고: scp man 


리모트 to 로컬 복사

로컬에서 다음 커맨드를 실행합니다.

scp {username}@{remote-host}:{remote-copy-from} {local-copy-to}

예시)

scp ariadacapo@111.222.333.444:~/backup ~/backup


SSH 포트를 변경했을 경우

SSH 포트를 변경하여 22와 다를 경우 -P 옵션을 추가합니다.

scp -P {remote-port} {remote-username}@{remote-host}:{remote-copy-from} {local-copy-to}

예시)

scp -P 1234 ariadacapo@111.222.333.444:~/backup ~/backup


디렉토리를 복사하는 경우

파일이 아니라 디렉토리를 복사하는 경우 -r 옵션을 추가합니다.

scp -r {remote-username}@{remote-host}:{remote-copy-from} {local-copy-to}

예시)

scp -r ariadacapo@111.222.333.444:~/backup ~/backup


로컬 to 리모트 복사

파라미터 순서 외에는 리모트 to 로컬 복사 방법과 동일합니다.

로컬에서 다음 커맨드를 실행합니다.

scp {local-copy-from} {username}@{remote-host}:{remote-copy-to}

예시)

scp  ~/backup ariadacapo@111.222.333.444:~/backup


SSH 포트를 변경했을 경우

SSH 포트를 변경하여 22와 다를 경우 -P 옵션을 추가합니다.

scp -P {remote-port} {local-copy-from} {remote-username}@{remote-host}:{remote-copy-to}

예시)

scp -P 1234 ~/backup ariadacapo@111.222.333.444:~/backup


디렉토리를 복사하는 경우

파일이 아니라 디렉토리를 복사하는 경우 -r 옵션을 추가합니다.

scp -r {local-copy-from} {remote-username}@{remote-host}:{remote-copy-to}

예시)

scp -r ~/backup ariadacapo@111.222.333.444:~/backup


2025년 6월 22일 일요일

Ubuntu 22.10 이후 버전 ssh port 변경

 Ubuntu 22.10 부터, 보다 정확히는 openssh-server 1:9.0p1-1ubuntu1부터 OpenSSH 설정이 달라졌습니다.

참조: https://discourse.ubuntu.com/t/sshd-now-uses-socket-based-activation-ubuntu-22-10-and-later/30189

위 글에 따르면 기본적으로 systemd socket activation을 사용하도록 바뀌었다고 합니다. 요청이 들어오기 전까지는 sshd를 실행시키지 않는 방식으로, 그 덕분에 메모리 소비를 최소 3MiB 줄일 수 있다고 합니다.

기본 설정이 바뀌었으므로 port를 변경하기 위한 방법도 달라졌습니다. 그래서 22.10 전 버전의 방식대로 /etc/ssh/sshd_config 파일을 수정하더라도 반영되지 않습니다.

두 가지 해결 방법이 있습니다.

1. 이전 버전으로 OpenSSH 설정을 되돌린다.

OpenSSH를 Ubuntu 22.10 전 방식으로 설정합니다. ssh.socket을 비활성화시키고, ssh.service를 활성화하는 식입니다.

systemctl disable --now ssh.socket
systemctl enable --now ssh.service

/etc/ssh/sshd_config 파일을 엽니다.

vi /etc/ssh/sshd_config

port 라인을 주석 해제한 후, 원하는 port로 변경합니다.

#...
#Port 22
#...

저는 31415 port로 변경해보겠습니다.

#...
Port 31415
#...

이제 ssh를 다시 시작합니다.

sudo systemctl restart ssh

2. ssh socket 설정 파일 변경

디렉토리를 생성합니다.

mkdir -p /etc/systemd/system/ssh.socket.d

생성한 디렉토리 내부에 listen.conf 파일을 작성합니다.

vi /etc/systemd/system/ssh.socket.d/listen.conf
[Socket]
ListenStream=
ListenStream=31415

파일이 수정되었으므로 daemon을 리로드하고, ssh.socket을 다시 시작합니다.

sudo systemctl daemon-reload
sudo systemctl restart ssh.socket

접속이 되지 않는 경우

여전히 접속이 되지 않는 경우 방화벽 설정(ex. ufw)을 살펴봅니다. 방화벽이 동작중인지, 동작중이라면 사용할 port의 접속이 허용되어 있는지 확인합니다.

ufw

ufw 동작 상태 확인

sudo systemctl status ufw

ufw 룰 확인

sudo ufw status verbose

2025년 6월 21일 토요일

Ubuntu 드라이브 마운트 및 디렉토리 복사

Ubuntu를 설치해서 운용했으나, 더이상 사용하지 않는 SSD에서 필요한 정보를 가져오기 위한 드라이브 마운트 및 디렉토리 복사 과정을 다룹니다.


드라이브 마운트

1. 드라이브를 연결합니다. 

2. 마운트할 드라이브를 확인합니다.

lsblk

3. 마운트될 디렉토리를 생성합니다.

sudo mkdir -p /mnt/my-drive

4. 드라이브를 마운트합니다.

sudo mount /dev/sda2 /mnt/my-drive


디렉토리 복사

마운트된 디렉토리 내부에서 필요한 디렉토리를 복사합니다.
아래 커맨드로 현재 경로로 디렉토리를 복사할 수 있습니다.

cp -r /mnt/my-drive/{from-path} .


권한이 필요할 경우

제 경우에는 다른 Ubuntu 시스템에서 사용하던 계정의 디렉토리에 접근해야 하기 때문에, 복사 커맨드 수행 시 Permission denied 오류가 발생했습니다.

cp -r /mnt/my-drive/home/wafflejuice/ .

곧바로 해당 디렉토리의 권한을 수정해도 되지만, 안전하게 복사 후에 권한을 수정하도록 합니다.


1. root 계정으로 전환합니다.

sudo -i

2. 디렉토리를 복사합니다.

cp -r /mnt/my-drive/home/wafflejuice/ ../home/ariadacapo/

기본적으로 특정한 경로를 설정하지 않으면 /root로 복사되기 때문에, 원래 계정에서 접근할 수 없게 됩니다. 따라서 원래 계정의 디렉토리로 복사합니다.

3. 이제 복사된 디렉토리의 권한을 수정하면 내부에 접근할 수 있습니다.

sudo chmod -R +rX wafflejuice/


드라이브 언마운트

볼일을 마쳤다면 언마운트합니다.

sudo umount /mnt/my-drive


2025년 6월 16일 월요일

Blogger custom domain 등록

Custom domain은 Blogger 관리 페이지의 Settings > Publishing 항목에서 설정할 수 있습니다.



구입한 도메인(ex. ariadacapo.com)으로 등록을 시도하면 다음과 같은 오류 문구가 나타납니다.


루트 도메인으로는 등록할 수 없다는 의미입니다. www.을 앞에 붙여 top-level domain으로 등록하거나, 원하는 문구를 앞에 붙여 subdomain으로 만들어주면 이 단계를 넘어갈 수 있습니다.

변경 후 (ex. blog.ariadacapo.com) 다시 등록을 시도하면 다음과 같은 오류 문구가 나타납니다.


해당 도메인의 소유권을 증명해야 한다는 의미입니다. 도메인 관리 서비스에서 두 개의 CNAME을 등록해야 합니다.
저는 Cloudflare에서 등록하도록 하겠습니다.


첫번째 CNAME을 등록합니다.
Type: CNAME 선택, Name: blog, Target: ghs.google.com을 입력하고, proxy를 비활성화한 뒤 저장합니다.
(Name은 루트 도메인 앞에 붙여준 문구에 따라 달라집니다.)


두번째 CNAME을 등록합니다.
Type: CNAME 선택, proxy 비활성화는 동일하지만, Name 및 Target은 사용자마다 달라지는 값입니다.


두 개의 CNAME이 정상적으로 등록되었는지 확인합니다.



다시 Blogger에서 Custom domain 등록을 시도하면 정상적으로 등록됩니다.
여전히 오류가 발생할 경우, CNAME 변경 후 실제 반영까지 시간이 소요되기 때문일 수 있으므로 잠시 기다린 뒤 다시 시도해봅니다.


참조

- https://support.google.com/blogger/answer/1233387