- 들어가며 — 3노드의 한계
- 1부 — 워커 추가는 시시할 만큼 쉽다
- 2부 — 컨트롤 플레인 추가에는 인증서 열쇠가 필요하다
- 3부 — "조인만 하면 HA"라는 착각
- 부록 — 현재 플랫폼 전체 스택
- 남은 과제
- 🧠 이해도 체크 퀴즈
- 참고 자료
들어가며 — 3노드의 한계
재구축한 클러스터는 컨트롤 플레인 1대에 GPU 워커 2대였습니다. 잘 돌았지만 두 가지가 아쉬웠습니다.
- 컨트롤 플레인이 한 대라 그 노드가 곧 클러스터입니다. 재부팅 한 번에 전체가 멈춥니다.
- GPU 실험을 늘리니 워커 2대로는 스케줄이 빡빡합니다.
마침 놀고 있는 미니 PC가 4대 있었습니다. 노트북 GPU가 달린 2대는 워커로, 저전력 2대는 컨트롤 플레인으로 붙이기로 했습니다.
목표 구성은 이렇습니다. (이 글의 IP와 호스트명은 실제 환경을 치환한 것입니다 — 10.0.0.0/24, cp-*/gpu-*)
| 노드 | 역할 | IP | 사양 |
|---|---|---|---|
| cp-1 | control-plane (기존) | 10.0.0.120 | 4C / 16GB |
| cp-2 | control-plane (신규) | 10.0.0.106 | 4C / 14GB |
| cp-3 | control-plane (신규) | 10.0.0.104 | 4C / 14GB |
| gpu-a | worker (기존) | 10.0.0.117 | RTX 3090 24GB |
| gpu-b | worker (기존) | 10.0.0.118 | RTX 5090 32GB |
| gpu-c | worker (신규) | 10.0.0.116 | RTX 4070 Laptop 8GB |
| gpu-d | worker (신규) | 10.0.0.115 | RTX 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할 때 단일 노드였으니 자연스러운 값이었지만, 이제 이 주소가 모든 곳에 박혀 있습니다.
- 워커 7대의 kubelet 설정 →
server: https://10.0.0.120:6443 - 모든 kubeconfig → 같은 주소
- apiserver 인증서 SAN →
IP:10.0.0.120(.106,.104는 없음)
그래서 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 LB | MetalLB (L2) | 풀 10.0.0.200–215 |
| L7 게이트웨이 | Cilium Gateway API | Gateway API CRD v1.6.1 필요 — v1.2로는 tlsroutes/referencegrants가 v1이 아니라 컨트롤러가 기동 거부 |
| 스토리지 | csi-driver-nfs + NAS | RWX 실측 검증, 기록 |
| GPU | GPU Operator v26.3.3 | GPU 4장 등록, runtime 사고 기록 |
| 가상화 | KubeVirt v1.9.0 + CDI | containerDisk 경로에 결함 — DataVolume(PVC) 경로로 우회해 Fedora VM 부팅 검증 |
| DB | CloudNativePG | PostgreSQL 18, 2인스턴스 스트리밍 복제 검증 |
| 관측 | kube-prometheus-stack | Grafana 10.0.0.203 |
| Git | Gitea | 10.0.0.200 |
| GitOps | ArgoCD | 10.0.0.201 |
| 레지스트리 | Harbor | 10.0.0.202 |
교훈 하나를 덧붙이면 — KubeVirt에서 겪은 일입니다. 컴포넌트 상태는 전부 AllComponentsReady였는데 VM은 뜨지 않았습니다. virt-launcher 파드 명세를 뜯어보니 init 컨테이너가 실행할 바이너리를 담은 볼륨 마운트가 누락돼 있었습니다. "상태가 Ready"와 "실제로 동작한다"는 다른 명제라는 걸 이 클러스터에서만 세 번째 확인합니다.
남은 과제
- kube-vip 도입 — 수동 IP 이전을 자동 페일오버로
- etcd 정기 스냅샷 — 쿼럼은 장애 대비이지 실수(오삭제) 대비가 아님
- 컨트롤 플레인 부하 관찰 — 4C/14GB 미니 PC가 etcd+apiserver를 얼마나 버티는지
- 이 위에 올릴 실습형 교육 플랫폼 — 수강생이 버튼을 누르면 실습 파드가 뜨고, 채점받고, 끝나면 사라지는 시스템. 다음 글의 주제입니다.
🧠 이해도 체크 퀴즈
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이 (클러스터 공통 인증서라) 포함돼 있으므로 검증이 통과합니다. 주소가 노드를 따라가는 게 아니라 노드가 주소를 이어받는 구조입니다.