LabHub

블로그

홈랩 쿠버네티스 3노드에서 7노드로 — 컨트롤 플레인 확장, etcd 쿼럼, 그리고 "조인만 하면 HA"라는 착각

들어가며 — 3노드의 한계

재구축한 클러스터는 컨트롤 플레인 1대에 GPU 워커 2대였습니다. 잘 돌았지만 두 가지가 아쉬웠습니다.

마침 놀고 있는 미니 PC가 4대 있었습니다. 노트북 GPU가 달린 2대는 워커로, 저전력 2대는 컨트롤 플레인으로 붙이기로 했습니다.

목표 구성은 이렇습니다. (이 글의 IP와 호스트명은 실제 환경을 치환한 것입니다 — 10.0.0.0/24, cp-*/gpu-*)

노드역할IP사양
cp-1control-plane (기존)10.0.0.1204C / 16GB
cp-2control-plane (신규)10.0.0.1064C / 14GB
cp-3control-plane (신규)10.0.0.1044C / 14GB
gpu-aworker (기존)10.0.0.117RTX 3090 24GB
gpu-bworker (기존)10.0.0.118RTX 5090 32GB
gpu-cworker (신규)10.0.0.116RTX 4070 Laptop 8GB
gpu-dworker (신규)10.0.0.115RTX 4070 Laptop 8GB

1부 — 워커 추가는 시시할 만큼 쉽다

워커 조인은 토큰 하나면 끝납니다.

# 컨트롤 플레인에서
kubeadm token create --print-join-command
# → kubeadm join 10.0.0.120:6443 --token <토큰> --discovery-token-ca-cert-hash sha256:<해시>

새 노드에서 이 명령에 --cri-socket--node-name만 붙여 실행하면 됩니다. 다만 두 노드 모두 예전 클러스터의 잔재(v1.32 kubelet, 옛 인증서)가 있어서 teardown → containerd 설치 → 조인 순서의 스크립트로 묶었습니다.

몇 분 뒤:

NAME     STATUS   ROLES           VERSION    INTERNAL-IP
gpu-c    Ready    <none>          v1.34.10   10.0.0.116
gpu-d    Ready    <none>          v1.34.10   10.0.0.115

GPU 라벨은 자동으로 붙는다 — 그런데 그걸로 부족하다

GPU Operator의 GPU Feature Discovery가 새 노드를 감지해 라벨을 답니다.

NODE     PRODUCT                              MEMORY   FAMILY
gpu-a    NVIDIA-GeForce-RTX-3090              24576    ampere
gpu-b    NVIDIA-GeForce-RTX-5090              32607    blackwell
gpu-c    NVIDIA-GeForce-RTX-4070-Laptop-GPU   8188     ada-lovelace
gpu-d    NVIDIA-GeForce-RTX-4070-Laptop-GPU   8188     ada-lovelace

여기서 실전 문제가 하나 나옵니다. 파드가 nvidia.com/gpu: 1 만 요청하면 32GB 5090이 필요한 학습이 8GB 노트북 GPU에 얹힐 수 있습니다. 쿠버네티스 입장에서는 둘 다 "GPU 1개"이기 때문입니다.

GFD의 gpu.memory 라벨은 문자열이라 "24GB 이상" 같은 비교 셀렉터가 안 됩니다. 그래서 의미 기반 라벨을 직접 얹었습니다.

kubectl label node gpu-b gpu.homelab/tier=xlarge gpu.homelab/vram=32g
kubectl label node gpu-a gpu.homelab/tier=large  gpu.homelab/vram=24g
kubectl label node gpu-c gpu.homelab/tier=small  gpu.homelab/vram=8g
kubectl label node gpu-d gpu.homelab/tier=small  gpu.homelab/vram=8g

이제 워크로드가 자기 체급을 고를 수 있습니다.

nodeSelector:
  gpu.homelab/tier: xlarge      # 큰 모델은 5090으로
resources:
  limits: { nvidia.com/gpu: 1 }

2부 — 컨트롤 플레인 추가에는 인증서 열쇠가 필요하다

워커와 달리 컨트롤 플레인 조인은 CA 개인키가 필요합니다. 새 노드도 인증서를 발급하는 주체가 되어야 하기 때문입니다. kubeadm은 이 민감한 파일을 scp로 나르게 하지 않고 우아한 방법을 씁니다.

kubeadm init phase upload-certs --upload-certs
# → certificate-key: 9d2c... (64자리 16진수)

이 명령은 CA 키 묶음을 암호화해서 클러스터 안의 Secret으로 올립니다. certificate-key는 그 암호문을 푸는 대칭키입니다. 조인 명령은 이렇게 완성됩니다.

kubeadm join 10.0.0.120:6443 \
  --token <토큰> \
  --discovery-token-ca-cert-hash sha256:<해시> \
  --control-plane \
  --certificate-key <64자리 키>

이 Secret은 2시간 뒤 자동 삭제됩니다. CA 키가 클러스터 안에 암호화 상태로나마 존재하는 시간을 최소화하려는 설계입니다. 2시간을 넘기면 upload-certs를 다시 실행하면 됩니다.

cp-2를 먼저, 완료 후 cp-3을 조인했습니다. 동시에 붙이지 않은 이유는 etcd 멤버 변경이 순차적이어야 쿼럼 계산이 흔들리지 않기 때문입니다. 1→2 전환 순간은 특히 예민합니다 — 멤버 2가 되는 순간 쿼럼도 2가 되어, 잠깐이라도 둘 중 하나가 응답을 놓치면 클러스터가 일시 정지합니다.

검증 — etcd 멤버 3개

$ kubectl -n kube-system exec etcd-cp-1 -- etcdctl member list ...
8aa467b3fa70568e  cp-1  https://10.0.0.120:2380
cddc52714f427433  cp-2  https://10.0.0.106:2380
c593a1acf312e1a7  cp-3  https://10.0.0.104:2380
NAME     STATUS   ROLES           VERSION    INTERNAL-IP
cp-1     Ready    control-plane   v1.34.10   10.0.0.120
cp-2     Ready    control-plane   v1.34.10   10.0.0.106
cp-3     Ready    control-plane   v1.34.10   10.0.0.104
gpu-a    Ready    <none>          v1.34.10   10.0.0.117
gpu-b    Ready    <none>          v1.34.10   10.0.0.118
gpu-c    Ready    <none>          v1.34.10   10.0.0.116
gpu-d    Ready    <none>          v1.34.10   10.0.0.115

7노드, etcd 3멤버. 각 컨트롤 플레인에 apiserver·scheduler·controller-manager가 하나씩 돌고 있습니다. 여기까지 보면 HA가 완성된 것 같습니다.

아닙니다.

3부 — "조인만 하면 HA"라는 착각

이 클러스터의 kubeadm 설정을 보면 함정이 드러납니다.

$ kubectl -n kube-system get cm kubeadm-config -o yaml | grep controlPlaneEndpoint
controlPlaneEndpoint: 10.0.0.120:6443     # ← cp-1의 물리 IP

controlPlaneEndpoint가 VIP나 DNS가 아니라 cp-1의 실제 IP입니다. 처음 init할 때 단일 노드였으니 자연스러운 값이었지만, 이제 이 주소가 모든 곳에 박혀 있습니다.

그래서 cp-1이 죽으면 이렇게 됩니다.

구성요소상태
etcd (cp-2, cp-3)✅ 쿼럼 2/3 유지, 데이터 무사
apiserver (cp-2, cp-3)✅ 프로세스 정상 동작 중
kubectl, kubelet ×7전부 접속 불가

컨트롤 플레인은 살아 있는데 아무도 문을 못 찾는 상태입니다. cp-2의 IP로 직접 붙자니 인증서 SAN에 없어서 TLS 검증이 실패합니다. etcd 쿼럼과 API 가용성은 완전히 별개의 문제였던 것입니다.

그래도 이 구성이 무의미하지 않은 이유

두 가지를 얻었습니다.

첫째, 데이터는 이제 안전합니다. cp-1의 디스크가 통째로 죽어도 클러스터 상태 전체가 cp-2·cp-3에 실시간 복제되어 있습니다. 단일 노드 시절에는 etcd 스냅샷 백업이 유일한 보험이었습니다.

둘째, 인증서에 박힌 .120을 역이용할 수 있습니다. cp-1이 죽었을 때:

# cp-2에서 — 죽은 cp-1의 주소를 가져온다
sudo ip addr add 10.0.0.120/24 dev <인터페이스>
sudo arping -c 3 -U -I <인터페이스> 10.0.0.120   # ARP 갱신

인증서가 .120을 커버하므로 모든 클라이언트가 재설정 없이 그대로 재접속합니다. 수동이지만 수 초짜리 복구이고, 인증서 재발급도 kubeconfig 수정도 필요 없습니다.

자동화하려면 kube-vip이나 keepalived로 이 IP 이전을 데몬화하면 됩니다 — 사실 kube-vip이 하는 일이 정확히 이것(VRRP로 VIP를 살아 있는 노드에 옮기기)입니다. 수동 절차를 먼저 이해하고 나면 도구가 무엇을 자동화하는지 명확해집니다.

처음부터 다시 한다면

kubeadm init 때 이렇게 했을 것입니다.

kubeadm init \
  --control-plane-endpoint=k8s-api.homelab.internal:6443 \   # DNS 또는 VIP
  --apiserver-cert-extra-sans=10.0.0.120,10.0.0.106,10.0.0.104

엔드포인트를 간접 주소로 두면 뒤에서 무엇이 바뀌든 클라이언트 설정은 그대로입니다. 이 값은 클러스터 생성 후 바꾸기가 몹시 고통스러운 몇 안 되는 설정이라, 단일 노드로 시작하더라도 미래를 위해 간접 주소를 넣어두는 편이 좋습니다.

부록 — 현재 플랫폼 전체 스택

확장과 병행해 플랫폼 구성요소를 하나씩 올렸습니다. 각각의 사연은 별도 글로 남겼거나 남길 예정이고, 여기서는 전체 그림만 기록합니다.

계층구성요소상태 · 비고
네트워크Cilium 1.20 (eBPF)kube-proxy 없음, L7 정책·Hubble·WireGuard 검증기
L4 LBMetalLB (L2)풀 10.0.0.200–215
L7 게이트웨이Cilium Gateway APIGateway API CRD v1.6.1 필요 — v1.2로는 tlsroutes/referencegrants가 v1이 아니라 컨트롤러가 기동 거부
스토리지csi-driver-nfs + NASRWX 실측 검증, 기록
GPUGPU Operator v26.3.3GPU 4장 등록, runtime 사고 기록
가상화KubeVirt v1.9.0 + CDIcontainerDisk 경로에 결함 — DataVolume(PVC) 경로로 우회해 Fedora VM 부팅 검증
DBCloudNativePGPostgreSQL 18, 2인스턴스 스트리밍 복제 검증
관측kube-prometheus-stackGrafana 10.0.0.203
GitGitea10.0.0.200
GitOpsArgoCD10.0.0.201
레지스트리Harbor10.0.0.202

교훈 하나를 덧붙이면 — KubeVirt에서 겪은 일입니다. 컴포넌트 상태는 전부 AllComponentsReady였는데 VM은 뜨지 않았습니다. virt-launcher 파드 명세를 뜯어보니 init 컨테이너가 실행할 바이너리를 담은 볼륨 마운트가 누락돼 있었습니다. "상태가 Ready"와 "실제로 동작한다"는 다른 명제라는 걸 이 클러스터에서만 세 번째 확인합니다.

남은 과제

🧠 이해도 체크 퀴즈

1. certificate-key로 푸는 Secret이 2시간 만에 삭제되는 이유는 무엇일까요?

그 Secret에는 암호화됐다고는 해도 클러스터의 루트 신뢰인 CA 개인키가 들어 있습니다. 노출 창을 최소화하기 위해 kubeadm은 2시간 TTL을 겁니다. 만료 후 컨트롤 플레인을 더 붙이려면 upload-certs를 다시 실행해 새 certificate-key를 발급받으면 되고, 기존 클러스터에는 아무 영향이 없습니다.

2. etcd 멤버가 3인데 cp-1이 죽으면 kubectl이 안 되는 이유는 무엇인가요?

etcd 쿼럼(2/3)과 apiserver 프로세스는 모두 정상이지만, 모든 클라이언트 — kubectl의 kubeconfig와 7개 노드의 kubelet 설정 — 가 cp-1의 물리 IP를 서버 주소로 갖고 있기 때문입니다. 게다가 apiserver 인증서 SAN에 다른 컨트롤 플레인의 IP가 없어서 cp-2로 직접 붙어도 TLS 검증이 실패합니다. 데이터 가용성과 접근 가용성은 별개입니다.

3. 컨트롤 플레인을 2대까지만 늘리면 오히려 위험해지는 이유는?

etcd 쿼럼은 과반입니다. 멤버 2의 과반은 2이므로, 한 대만 죽어도 쿼럼을 잃고 클러스터 전체가 쓰기 불능이 됩니다. 즉 2멤버는 1멤버보다 장애 확률이 오히려 높습니다(둘 중 아무나 죽으면 정지). 홀수 구성(1, 3, 5)이 권장되는 이유이고, 그래서 이번 확장도 반드시 3까지 갔습니다.

4. cp-1 장애 시 IP를 cp-2로 옮기는 수동 페일오버가 인증서 재발급 없이 동작하는 이유는?

클라이언트들이 검증하는 것은 "접속한 주소가 인증서 SAN에 있는가"입니다. cp-2가 .120을 자기 인터페이스에 추가하면, 클라이언트는 여전히 .120으로 접속하고 cp-2의 apiserver가 제시하는 인증서에는 .120이 (클러스터 공통 인증서라) 포함돼 있으므로 검증이 통과합니다. 주소가 노드를 따라가는 게 아니라 노드가 주소를 이어받는 구조입니다.

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다