kubeadm으로 온프레미스 Kubernetes 클러스터를 처음부터 세우는 절차
Kubernetes가 왜 필요한지부터, VM 2~3대 준비, kubeadm init, CNI(Calico), MetalLB, 스토리지, 그리고 선택 확장인 kube-vip HA·Cilium 마이그레이션까지 - 개념과 실제 절차·명령어 정리.
목표
VM 23대 위에 kubeadm으로 컨트롤플레인 1대 + 워커 12대 구성의 클러스터를 세운다. 여기에 CNI(Calico), LoadBalancer(MetalLB), 스토리지(NFS 또는 Longhorn)까지 붙여 "서비스를 배포할 수 있는 최소 상태"를 만드는 게 이 시리즈의 목표다. (선택 확장으로 kube-vip 컨트롤플레인 HA, Calico→Cilium 마이그레이션도 다룬다.)
개념: Kubernetes가 왜 필요한가
서버 한 대에 컨테이너 몇 개를 직접 docker run으로 띄우는 건 어렵지 않다. 문제는 서버가 여러 대이고, 그중 하나가 죽었을 때 그 위에 있던 컨테이너를 다른 서버로 옮겨야 하고, 트래픽이 늘면 컨테이너 수를 늘려야 하고, 배포할 때마다 이 모든 걸 손으로 반복해야 한다는 것이다. Kubernetes는 여러 대의 서버(노드)를 하나의 자원 풀처럼 묶어서, "컨테이너를 몇 개, 어떤 상태로 띄우고 싶다"고 선언만 하면 나머지(배치·재시작·복구)를 대신 해주는 시스템이다.
클러스터는 역할이 다른 두 종류의 노드로 나뉜다.
컨트롤플레인 (두뇌 - 무엇을 어디에 둘지 결정)
├─ API Server : 모든 요청(kubectl, kubelet 등)이 지나가는 유일한 창구
├─ etcd : 클러스터의 모든 상태를 저장하는 분산 DB (보통 홀수 개로 구성 - 과반수 투표로 장애 판단)
├─ Scheduler : 새로 뜰 파드를 어느 워커 노드에 배치할지 결정
└─ Controller Manager : "원하는 상태"(선언한 값)와 "실제 상태"를 계속 비교해서 맞춰나감
워커 노드 (몸 - 실제로 컨테이너가 도는 곳)
├─ kubelet : 이 노드에서 컨트롤플레인의 지시대로 컨테이너를 실행·감시
├─ 컨테이너 런타임(containerd) : 실제로 컨테이너를 실행하는 엔진
└─ kube-proxy : Service로 들어온 트래픽을 알맞은 파드로 전달 (이 글 뒤쪽 Cilium 절에서 대체 이유를 다룸)
왜 관리형 Kubernetes(EKS/GKE 등)를 안 쓰고 kubeadm으로 직접 세우는가: 관리형 서비스는 컨트롤플레인 운영(etcd 백업, API 서버 이중화, 버전 업그레이드) 자체를 클라우드 사업자가 대신 해준다. 그 "대신 해주던 일"이 정확히 무엇이었는지는, 직접 컨트롤플레인을 세우고 고장 내보기 전까지는 실감하기 어렵다 - 이 시리즈의 핵심 학습 목표가 바로 그 부분이다.
사전 준비
| 항목 | 최소 사양 | 비고 |
|---|---|---|
| VM 수 | 3대 (컨트롤플레인 1 + 워커 2, 또는 1+1) | 컨트롤플레인/워커 겸용도 가능하지만 학습 목적상 분리 권장 |
| OS | Ubuntu 22.04 LTS | kubeadm 공식 지원 |
| CPU/메모리 | 컨트롤플레인 2vCPU/2GB 이상, 워커 2vCPU/4GB 이상 | |
| 네트워크 | 노드 간 통신 가능한 사설망, 고정 IP | DHCP면 고정 임대로 전환 |
| 스왑 | 비활성화 | kubelet은 기본적으로 스왑을 허용하지 않음 |
모든 노드 공통으로 사전에 처리해야 하는 것:
# 스왑 비활성화 (영구)
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
# 커널 모듈 + sysctl
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
# 컨테이너 런타임(containerd) 설치
sudo apt-get update && sudo apt-get install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
# SystemdCgroup = false → true 로 수정
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
overlay·br_netfilter는 왜 필요한가 - 이 둘 없으면 컨테이너도 CNI 정책도 안 된다
overlay(OverlayFS) 먼저 보면, 컨테이너 이미지는 레이어를 쌓아 만든다 - 베이스 OS 레이어 위에 패키지 설치 레이어, 그 위에 애플리케이션 코드 레이어를 얹는 식이다. 이 레이어들은 전부 읽기 전용이고, 컨테이너가 실행되면 그 위에 아주 얇은 쓰기 가능 레이어 하나만 추가된다. OverlayFS는 이 여러 레이어를 하나의 파일시스템처럼 보이게 합쳐주는 커널 기능이다.
이 구조 덕분에 같은 베이스 이미지(예: python:3.11-slim)를 쓰는 컨테이너 100개를 띄워도, 디스크에는 그 베이스 레이어가 한 벌만 존재한다 - 100개 컨테이너는 전부 그 한 벌을 읽기 전용으로 공유하고, 각자 자기 쓰기 레이어에만 변경사항을 쌓는다. 컨테이너를 지우면 그 쓰기 레이어만 사라지고 베이스는 그대로 남아 다음 컨테이너가 또 재사용한다 - 이게 이미지를 매번 통째로 복사하지 않고도 컨테이너를 가볍게 띄울 수 있는 이유다.
br_netfilter는 완전히 다른 영역이다. 리눅스 브리지(파드들을 연결하는 가상 스위치)를 통과하는 트래픽은 기본적으로 iptables/넷필터를 거치지 않는다. 브리지는 원래 "같은 스위치에 물린 장치끼리는 그냥 통과시킨다"가 기본 동작이기 때문이다.
br_netfilter 커널 모듈은 이 기본 동작을 뒤집어서, 브리지를 통과하는 트래픽도 iptables 체인을 거치게 만든다. 이 모듈이 로드되고 net.bridge.bridge-nf-call-iptables = 1이 설정돼 있어야만, kube-proxy의 Service NAT 규칙과 CNI의 NetworkPolicy가 파드 간 통신에 실제로 적용된다.
br_netfilter 없음
파드 브리지 트래픽 → 넷필터 우회 → iptables 규칙 무의미
br_netfilter 있음
파드 브리지 트래픽 → iptables 체인 통과 → Service NAT · NetworkPolicy 적용 가능
정리하면 overlay는 "컨테이너가 가볍게 뜰 수 있는 바닥"이고, br_netfilter는 "CNI가 정책을 걸 수 있는 바닥"이다. 둘 다 스왑 비활성화나 컨테이너 런타임 설치처럼 사전 준비 목록에 나란히 있어서 지나치기 쉽지만, br_netfilter가 빠지면 뒤에서 아무리 정교한 NetworkPolicy를 선언해도 실제로는 적용되지 않는다.
실제 구축 절차
1. kubeadm/kubelet/kubectl 설치 (모든 노드)
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.30/deb/Release.key | \
sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
https://pkgs.k8s.io/core:/stable:/v1.30/deb/ /' | \
sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl
kubeadm은 클러스터를 세우는 도구, kubelet은 각 노드에서 실제로 돌아가며 컨테이너를 관리하는 에이전트, kubectl은 사람이 클러스터에 명령을 내리는 CLI다 - 셋은 서로 다른 역할이라 세 개 다 설치해야 한다.
2. 컨트롤플레인 초기화
sudo kubeadm init \
--pod-network-cidr=192.168.0.0/16 \
--control-plane-endpoint="<컨트롤플레인 고정 IP 또는 VIP>:6443" \
--upload-certs
--pod-network-cidr: Calico 기본값(192.168.0.0/16)과 맞춘다. 다른 CNI를 쓰면 그 CNI의 기본 CIDR을 확인한다.--control-plane-endpoint: 지금 당장은 컨트롤플레인 1대의 IP를 쓰되, 이후 kube-vip로 이중화할 계획이면 처음부터 VIP를 넣어두는 게 낫다 - 나중에 바꾸면 인증서를 재발급해야 한다.- 완료 후 출력되는
kubeadm join명령어(토큰 포함)를 워커 노드용으로 저장해둔다. 토큰은 24시간 후 만료되므로, 나중에 필요하면kubeadm token create --print-join-command로 재생성한다.
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
3. 워커 노드 조인
sudo kubeadm join <컨트롤플레인 IP>:6443 \
--token <토큰> \
--discovery-token-ca-cert-hash sha256:<해시>
kubectl get nodes로 워커가 NotReady 상태로 나타나면 정상이다 - CNI가 아직 없어서 그렇다.
4. CNI 설치 - Calico
CNI(Container Network Interface)란: Kubernetes 자체는 "파드끼리 서로 다른 노드에 있어도 통신되게 만드는 방법"을 직접 구현하지 않는다. 대신 CNI라는 표준 인터페이스를 정의해두고, 그 구현은 플러그인에 맡긴다. Calico는 그 구현체 중 하나다 - CNI가 없으면 파드는 생성되지만 서로 다른 노드의 파드끼리는 통신할 방법이 없다(워커 노드가 계속 NotReady인 이유).
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/custom-resources.yaml
custom-resources.yaml을 적용하기 전에 spec.calicoNetwork.ipPools[0].cidr 값이 kubeadm init에 넣은 --pod-network-cidr과 일치하는지 반드시 확인한다. 여기가 어긋나면 파드끼리 통신이 안 되는 문제로 이어진다.
kubectl get nodes # 모든 노드가 Ready로 바뀌는지 확인
kubectl get pods -n calico-system
5. MetalLB - 온프레미스에서 LoadBalancer 타입 서비스 쓰기
클라우드에서 type: LoadBalancer 서비스를 만들면, 클라우드 사업자가 그 뒤에서 실제 로드밸런서 장비를 붙이고 외부 IP를 배정해준다. 온프레미스에는 그 역할을 해줄 사업자가 없다 - MetalLB가 그 역할을 소프트웨어로 흉내낸다.
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.8/config/manifests/metallb-native.yaml
L2 모드(가장 단순한 구성)로 IP 풀을 지정한다.
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: default-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.240-192.168.1.250 # 노드가 속한 네트워크 대역에서 안 쓰는 구간
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: default-l2
namespace: metallb-system
spec:
ipAddressPools:
- default-pool
L2 모드는 클러스터 노드 중 하나가 ARP로 "이 IP는 나한테 있다"고 응답하는 방식이다 - 별도 라우터 설정 없이 가장 단순하게 시작할 수 있지만, 그 노드가 죽으면 잠깐의 전환 시간이 생긴다는 트레이드오프가 있다.
이 랩을 AWS EC2 위에서 그대로 돌려보면 MetalLB가 왜 안 먹히는가
이 시리즈는 온프레미스 VM 기준으로 쓰여 있지만, 실제로는 AWS EC2 위에 VM 3대를 올려서 그대로 재현해봤다. 그런데 MetalLB L2 모드를 위 설정 그대로 적용하면 두 가지가 어긋난다.
- L2/ARP가 VPC에서 아예 동작하지 않는다. AWS의 소프트웨어 정의 네트워크(VPC)는 ENI에 명시적으로 할당되지 않은 임의의 IP에 대한 ARP 응답·브로드캐스트 자체를 허용하지 않는다. 온프레미스에서 통하던 "노드 중 하나가 ARP로 대신 응답한다"는 방식이 성립하는 전제(같은 물리 스위치의 브로드캐스트 도메인)가 VPC에는 없다.
- IP 풀 대역이 파드 CIDR과 충돌한다. 위 예시의
192.168.1.240-250은 Calico 기본 파드 CIDR192.168.0.0/16안에 포함된다 - 온프레미스 튜토리얼 자료를 그대로 복사했을 때 흔히 걸리는 함정이다.
AWS에서의 실무 정답은 MetalLB가 아니라 애초에 다른 도구 조합이다. 클라우드에서는 type: LoadBalancer Service를 실제 로드밸런서(NLB/ALB)로 프로비저닝해주는 AWS Load Balancer Controller와, 그 로드밸런서 주소를 Route 53 도메인에 자동으로 연결해주는 ExternalDNS를 함께 쓰는 게 일반적이다. MetalLB 하나가 하던 "가짜 클라우드 LB 역할"을, AWS에서는 이 두 컨트롤러가 "실제 LB 프로비저닝 + 실제 DNS 연결"로 나눠서 맡는 셈이다. MetalLB를 굳이 학습 목적으로 실습하려면 L2 대신 BGP 모드로 바꾸거나, 아예 AWS 네이티브 조합으로 갈아타야 한다.
6. 스토리지 - NFS 또는 Longhorn
파드는 기본적으로 상태가 없다 - 파드가 재시작되면 그 안에 쓴 파일도 함께 사라진다. DB처럼 데이터를 유지해야 하는 워크로드는 파드 바깥의 별도 저장소에 데이터를 두고, 파드가 그걸 마운트해서 쓴다. PersistentVolumeClaim은 "이 정도 용량과 특성의 저장공간을 달라"는 요청서고, StorageClass는 그 요청을 실제 저장소 구현(NFS/Longhorn 등)에 연결해주는 설정이다.
여기서 처음 나오는 Helm은 뭔가: Kubernetes 리소스는 보통 여러 개의 yaml(Deployment, Service, ConfigMap...)로 흩어져 있는데, Helm은 이걸 "차트(chart)"라는 패키지 하나로 묶어 설치·업그레이드·삭제까지 명령 한 줄로 처리하게 해주는 패키지 매니저다 - apt/npm의 Kubernetes 버전이라고 보면 된다. helm repo add는 차트 저장소를 등록하는 것이고, helm install이 그 저장소의 차트를 실제로 클러스터에 설치하는 명령이다. 이 시리즈 전반에서 nfs-provisioner·Longhorn·Cilium 같은 복잡한 컴포넌트를 매번 yaml 여러 개를 손으로 짜지 않고 Helm으로 설치하는 이유가 이것이다.
NFS (더 단순, 단일 장애점 있음): 별도 NFS 서버(또는 노드 중 하나)에 nfs-kernel-server를 띄우고, nfs-subdir-external-provisioner를 Helm으로 설치해 StorageClass를 등록한다.
helm repo add nfs-subdir-external-provisioner https://kubernetes-sigs.github.io/nfs-subdir-external-provisioner/
helm install nfs-provisioner nfs-subdir-external-provisioner/nfs-subdir-external-provisioner \
--set nfs.server=<NFS 서버 IP> \
--set nfs.path=/exported/path
Longhorn (분산 블록 스토리지, 복제 지원): 노드마다 데이터가 복제되므로 노드 하나가 죽어도 볼륨이 살아있다는 이점이 있다. 대신 각 노드에 open-iscsi 등 의존성 설치가 필요하다.
helm repo add longhorn https://charts.longhorn.io
helm install longhorn longhorn/longhorn --namespace longhorn-system --create-namespace
두 개 중 하나만 고르면 된다 - 이 프로젝트 규모에서는 NFS로 시작해도 충분하고, 이후 노드 장애 실험(시리즈 6)에서 스토리지 복제가 필요하다고 판단되면 Longhorn으로 바꾼다.
검증 방법
kubectl get nodes -o wide # 전 노드 Ready
kubectl get pods -A | grep -v Running # Running이 아닌 파드가 없어야 정상
kubectl apply -f - <<EOF
apiVersion: v1
kind: Service
metadata:
name: test-lb
spec:
type: LoadBalancer
selector: {app: nonexistent}
ports: [{port: 80}]
EOF
kubectl get svc test-lb # EXTERNAL-IP가 MetalLB 풀에서 할당되는지 확인
kubectl delete svc test-lb
스토리지는 PersistentVolumeClaim을 하나 만들어 Bound 상태로 전환되는지 확인한다.
(선택 확장) kube-vip로 컨트롤플레인 3노드 HA
개념: 컨트롤플레인이 1대뿐이면, 그 노드가 죽는 순간 클러스터 전체를 조작할 방법이 사라진다 - kubectl도 안 되고, 새 파드 스케줄링도 멈춘다. 컨트롤플레인을 3대로 늘리면 etcd가 과반수(3개 중 2개) 투표로 정상 여부를 판단하기 때문에 1대가 죽어도 나머지 둘로 유지된다. 문제는 "3대 중 지금 어디로 접속해야 하는가"다 - 특정 노드의 IP를 그대로 쓰면 그 노드가 죽었을 때 접속 주소 자체가 사라진다.
kube-vip는 3대 앞에 **고정된 가상 IP(VIP)**를 두고, 살아있는 노드 중 하나가 리더 선출(leader election)로 그 VIP를 응답하도록 자동으로 넘겨준다. 클라이언트(kubectl, 워커의 kubelet 등)는 항상 같은 VIP로만 접속하면 된다.
# /etc/kubernetes/manifests/kube-vip.yaml (각 컨트롤플레인 노드에 동일하게 배치 - static pod)
apiVersion: v1
kind: Pod
metadata:
name: kube-vip
namespace: kube-system
spec:
containers:
- name: kube-vip
image: ghcr.io/kube-vip/kube-vip:v0.8.0
args: ["manager"]
env:
- {name: vip_arp, value: "true"}
- {name: address, value: "<VIP>"}
- {name: vip_interface, value: "eth0"}
- {name: cp_enable, value: "true"}
- {name: cp_namespace, value: "kube-system"}
securityContext:
capabilities: {add: ["NET_ADMIN", "NET_RAW"]}
volumeMounts:
- {name: kubeconfig, mountPath: /etc/kubernetes/admin.conf}
hostNetwork: true
volumes:
- {name: kubeconfig, hostPath: {path: /etc/kubernetes/admin.conf}}
컨트롤플레인 노드 2·3대째는 kubeadm join --control-plane(1단계에서 --upload-certs로 받은 인증서 키 사용)으로 추가한다.
sudo kubeadm join <VIP>:6443 \
--token <토큰> \
--discovery-token-ca-cert-hash sha256:<해시> \
--control-plane --certificate-key <upload-certs 키>
검증 실험 - 컨트롤플레인 1대 강제 종료:
# 리더 역할을 하고 있는 컨트롤플레인 노드를 강제 종료
sudo shutdown -h now
# 다른 노드에서 확인할 것
kubectl get nodes # VIP를 통한 kubectl 접속이 계속 되는지
kubectl -n kube-system get pods -l component=etcd # 남은 etcd 멤버들이 여전히 정상인지
# 죽은 노드를 되살린 뒤 etcd에 다시 합류하는지도 확인
(선택 확장) Calico → Cilium 마이그레이션
개념: CNI는 교체 가능한 표준 인터페이스이므로, 다른 구현체로 바꿀 수 있다(다만 무중단 교체는 까다로워 보통 재구축이 안전하다). 이 교체가 왜 의미 있는지 이해하려면 먼저 kube-proxy가 하는 일을 알아야 한다 - kube-proxy는 Service가 생성·변경될 때마다 모든 노드의 iptables 규칙을 다시 계산해서 갱신하는 방식으로 트래픽을 라우팅한다. 서비스·노드 수가 늘어날수록 이 규칙 갱신 자체가 부하가 된다.
Cilium은 eBPF(리눅스 커널 안에서 안전하게 실행되는 프로그램)를 기반으로 하는 CNI로, kube-proxy가 iptables로 하던 일을 커널 레벨에서 직접 처리해 더 빠르고, L3/L4뿐 아니라 L7(HTTP 경로·메서드 단위)까지 네트워크 정책을 걸 수 있다.
# 기존 Calico 제거 (재구축 권장 - 무중단 교체는 리스크가 큼)
kubectl delete -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/custom-resources.yaml
kubectl delete -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml
# Cilium 설치 (kube-proxy 완전 대체 모드)
helm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium --version 1.16.0 \
--namespace kube-system \
--set kubeProxyReplacement=true \
--set k8sServiceHost=<컨트롤플레인 IP 또는 VIP> \
--set k8sServicePort=6443
cilium status # kube-proxy replacement가 "True"로 뜨는지 확인
cilium connectivity test # 파드 간 통신, 정책 적용이 정상인지 종합 검증
iptables와 eBPF, 실제로 뭐가 다른가
kube-proxy가 iptables로 Service 라우팅을 처리하는 방식은 규칙 체인 순회다. 패킷 하나가 들어올 때마다 등록된 규칙을 위에서부터 순서대로 대조한다 - Service·엔드포인트가 늘어날수록 패킷 하나를 처리하는 데 확인해야 할 규칙 수도 함께 늘어난다.
eBPF는 다르다. 커널 안에 작은 프로그램을 심어서, 패킷을 해시맵 조회 한 번으로 목적지를 찾는다. 규칙(서비스) 개수가 늘어나도 조회 비용이 거의 일정하다. kubeProxyReplacement=true로 Cilium이 kube-proxy를 완전히 대체하면, Service 라우팅 자체가 iptables 체인을 아예 거치지 않고 이 경로로 처리된다.
여기에 더해 iptables는 L3/L4(IP·포트) 단위까지만 본다. Cilium은 eBPF 프로그램이 애플리케이션 계층 프로토콜(HTTP)까지 파싱할 수 있어서, "이 파드는 호출 가능하지만 DELETE /admin/*은 안 된다" 같은 L7 정책도 같은 계층에서 표현할 수 있다 - 이게 CNI를 Calico에서 Cilium으로 바꾸는 실질적인 이유다.
Cilium의 관측 도구인 Hubble은 실제 트래픽 흐름을 커널 레벨에서 그대로 보여준다. 이 랩에서 비동기 파이프라인(api-server → RabbitMQ → inference-worker → PostgreSQL)을 돌려보면서 hubble observe로 캡처해보니, api-server와 inference-worker 사이에는 직접 연결이 단 한 번도 없었다 - 큐를 통해서만 간접적으로 이어지는 아키텍처가 설계 문서가 아니라 실제 패킷 기록으로 증명된 셈이다.

번외: AWS로 이 랩을 돌리면서 비용은 어떻게 관리했나
학습용 클러스터라 상시로 켜둘 이유가 없어서, 인스턴스 자체를 **스팟(persistent, 중단 시 stop)**으로 띄웠다 - 온디맨드보다 저렴하고, 스팟 회수(interruption)가 발생해도 종료가 아니라 정지 상태로 남아서 데이터가 보존된다. 여기에 세션이 끝나는 저녁마다 aws ec2 stop-instances로 3대를 수동 정지해서 컴퓨팅 과금 자체를 껐다 - EBS(디스크)는 정지 중에도 과금되지만 컴퓨팅에 비하면 미미하고, etcd·Longhorn·DB 데이터는 그대로 보존된다.
Elastic IP(고정 공인 IP)도 일부러 안 붙였다 - 붙이면 인스턴스를 재시작해도 주소가 안 바뀌어서 편하지만, 학습용 랩 규모에서 굳이 그 비용을 낼 이유가 없다고 판단했다. 대신 재시작할 때마다 바뀌는 새 공인 IP를 스크립트로 조회해서 로컬 hosts 파일을 자동 갱신하는 쪽을 택했다 - 클러스터 내부 통신은 Private IP(고정) 기준이라 재시작 자체에는 영향이 없고, 바뀌는 건 브라우저로 접속할 때 쓰는 공인 IP뿐이었다.
"당연히 필요할 것 같은 옵션(EIP)"을 규모에 안 맞으면 빼는 것도, MetalLB처럼 "온프레미스에서 당연한 것"을 클라우드에 그대로 옮기지 않는 것과 같은 결의 판단이다 - 실제 필요와 무관하게 익숙한 패턴을 그대로 가져오면 안 되는 경우가 있다.
체크리스트
- VM 2~3대에 kubeadm으로 클러스터 구축
- CNI 설치 (Calico)
- MetalLB, 스토리지(NFS 또는 Longhorn) 구성
- 클러스터 파괴 후 재구축 1회 (
kubeadm reset→ 위 절차 반복) - (선택) 클라우드 K8s와 하이브리드 연동
- (선택 확장) kube-vip로 컨트롤플레인 3노드 HA + 강제 종료 실험
- (선택 확장) Calico → Cilium 마이그레이션 +
cilium connectivity test통과