kubeadm은 Kubernetes 공식 클러스터 설치 도구로, 노드를 준비한 뒤 init과 join으로 클러스터를 만듭니다. 마스터를 여러 대 두려면 처음부터 공통 API 주소를 정해야 하고, 네트워크(CNI)와 외부 노출에 필요한 구성 요소는 따로 설치합니다. 이 글에서는 Ubuntu 24.04 노드 5대로 마스터 3대 클러스터를 만들고, kube-vip로 API 가상 IP를, Cilium으로 kube-proxy 없는 네트워크를, MetalLB와 Gateway API로 외부 노출을 구성한 과정을 명령과 함께 정리합니다. 컨테이너부터 Kubernetes까지 시리즈 12편이며, 이전 글은 [Kubernetes] 리소스 requests, limits와 HPA 정리입니다. 이 클러스터를 홈랩에서 어떻게 설계하고 운영했는지는 홈랩 재구성 시리즈에서 다루었습니다.
1. 구성
| 노드 | 사양과 IP |
| 마스터 3대 | vCPU 2개, 메모리 4GB, 디스크 40GB, 10.10.0.21~23 |
| 워커 2대 | vCPU 4개, 메모리 12GB, 디스크 80GB, 10.10.0.24~25 |
| API 가상 IP | 10.10.0.20 (kube-vip) |
| LoadBalancer 대역 | 10.10.0.240~249 (MetalLB) |
IP는 예시 대역입니다. kube-vip와 MetalLB의 L2(ARP) 방식은 노드가 모두 같은 서브넷에 있어야 동작합니다.

API 주소는 kube-vip 가상 IP로, 네트워크는 Cilium으로, 외부 노출은 MetalLB와 Gateway API로 구성했습니다
| 구성 요소 | 버전 |
| OS | Ubuntu 24.04.5 LTS, 커널 6.8 |
| Kubernetes | 1.36.5 |
| containerd | 2.2.1 (Ubuntu 기본 저장소) |
| kube-vip | 1.2.4 |
| Cilium | 1.20.2 |
| MetalLB | 0.16.1 |
| Gateway API CRD | 1.6.1 |
| cert-manager | 1.21.2 |
| Argo CD | 3.5.5 (차트 10.10.3) |
버전은 공식 문서상 서로 지원하는 조합으로 골랐습니다. Cilium 1.20이 Kubernetes 1.36까지만 지원하므로 1.36.5를 씁니다.
2. 노드 준비
kubelet은 기본적으로 스왑이 켜져 있으면 시작하지 않으므로, Ubuntu autoinstall 단계에서 스왑을 껐습니다(storage.swap.size: 0). 그다음 모든 노드에서 커널 모듈과 sysctl, containerd, kubeadm을 준비합니다.
# 1) 커널 모듈과 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.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF
sudo sysctl --system
# 2) containerd (Ubuntu 24.04 기본 저장소에 2.x가 있음) + systemd cgroup 드라이버
sudo apt-get update
sudo apt-get install -y containerd apt-transport-https ca-certificates curl gpg
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
# 3) kubeadm, kubelet, kubectl (마이너 버전 저장소 고정)
K8S_MINOR=v1.36
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://pkgs.k8s.io/core:/stable:/$K8S_MINOR/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:/$K8S_MINOR/deb/ /" | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl cri-tools
echo 'runtime-endpoint: unix:///run/containerd/containerd.sock' | sudo tee /etc/crictl.yaml
sudo apt-mark hold kubelet kubeadm kubectl
| 항목 | 이유 |
| SystemdCgroup = true | cgroup v2와 systemd를 쓰는 Ubuntu 24.04에 맞춰 kubelet과 containerd의 cgroup 드라이버를 systemd로 통일 |
| cri-tools | kubeadm 사전 점검에 crictl이 필요하지만 kubeadm 패키지가 함께 설치하지 않아 따로 설치 |
| apt-mark hold | apt upgrade로 버전이 바뀌지 않게 고정 |
이 단계에서 kubelet이 멈춰 있거나 재시작을 반복하는 것은 정상입니다. init이나 join으로 설정이 생기면 실행됩니다.
3. kube-vip로 API 가상 IP 만들기
kube-vip는 마스터 노드에서 정적 Pod로 실행되고, 마스터끼리 뽑은 리더가 가상 IP를 ARP로 광고합니다. 별도 로드밸런서 없이 API 주소를 이중화할 수 있습니다. 첫 번째 마스터에서 매니페스트를 만듭니다.
sudo ctr -n k8s.io image pull ghcr.io/kube-vip/kube-vip:v1.2.4
sudo ctr -n k8s.io run --rm --net-host ghcr.io/kube-vip/kube-vip:v1.2.4 vipgen /kube-vip manifest pod \
--interface ens192 --address 10.10.0.20 --controlplane --arp --leaderElection \
| sudo tee /etc/kubernetes/manifests/kube-vip.yaml
# 첫 init 동안에만 super-admin.conf를 쓴다
sudo sed -i 's#path: /etc/kubernetes/admin.conf#path: /etc/kubernetes/super-admin.conf#' /etc/kubernetes/manifests/kube-vip.yaml
마지막 줄이 핵심입니다. kubeadm 1.29부터 admin.conf는 kubeadm:cluster-admins 그룹 권한으로 동작하는데, 첫 init 도중에는 이 그룹에 아직 권한이 없습니다. 그래서 kube-vip가 리더를 선출하지 못해 가상 IP가 활성화되지 않고, init은 시간 초과로 실패합니다. 첫 번째 마스터만 init 동안 super-admin.conf를 쓰고, 끝나면 되돌립니다.
4. kubeadm init
# kubeadm-init.yaml
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 10.10.0.21
bindPort: 6443
nodeRegistration:
criSocket: unix:///run/containerd/containerd.sock
skipPhases:
- addon/kube-proxy # Cilium이 kube-proxy를 대신한다
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: v1.36.5
clusterName: homelab
controlPlaneEndpoint: 10.10.0.20:6443 # 처음부터 가상 IP로 지정
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
apiServer:
certSANs:
- 10.10.0.20
controllerManager:
extraArgs:
- name: bind-address
value: 0.0.0.0 # 클러스터 안의 모니터링이 지표를 가져갈 수 있게
scheduler:
extraArgs:
- name: bind-address
value: 0.0.0.0
| 설정 | 의미 |
| controlPlaneEndpoint | 모든 노드와 인증서가 쓸 API 주소, 나중에 바꾸기 어려워 처음부터 가상 IP로 |
| skipPhases: addon/kube-proxy | kube-proxy를 설치하지 않음, Cilium의 kubeProxyReplacement가 대신함 |
| podSubnet | Cilium의 ipam.mode=kubernetes가 이 대역에서 노드별 Pod 대역을 받아 씀 |
| bind-address | 컨트롤러 매니저와 스케줄러는 기본값 127.0.0.1에서만 지표를 제공해 다른 노드에서 수집할 수 없음 |
sudo kubeadm config validate --config kubeadm-init.yaml
sudo kubeadm config images pull --kubernetes-version v1.36.5 # 마스터 3대에서 미리 받아 둠
sudo kubeadm init --config kubeadm-init.yaml --upload-certs
# init이 끝나면 kube-vip를 admin.conf로 되돌린다
sudo sed -i 's#super-admin.conf#admin.conf#' /etc/kubernetes/manifests/kube-vip.yaml
bind-address를 노드 IP로 두면 127.0.0.1로 확인하는 liveness probe가 실패해 컴포넌트가 계속 다시 시작되므로 0.0.0.0으로 둡니다. 이미 만든 클러스터라면 마스터마다 정적 Pod 설정을 고치고, kubeadm-config ConfigMap에도 같은 값을 넣어야 upgrade 뒤에도 유지됩니다.
--upload-certs는 마스터용 인증서를 암호화해 올려 두어, 다른 마스터가 join할 때 certificate-key로 받게 합니다. 출력된 토큰과 키는 인증 정보이므로 기록에 남기지 않습니다.
5. 노드 join
# 마스터 2, 3
sudo kubeadm join 10.10.0.20:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash> \
--control-plane --certificate-key <key> \
--apiserver-advertise-address 10.10.0.22
# join이 끝나면 첫 번째 마스터의 kube-vip.yaml(admin.conf 버전)을 /etc/kubernetes/manifests/에 복사한다
# 워커
sudo kubeadm join 10.10.0.20:6443 --token <token> --discovery-token-ca-cert-hash sha256:<hash>
kubectl label node k8s-worker-1 k8s-worker-2 node-role.kubernetes.io/worker=
토큰은 기본 24시간 뒤 만료되므로, 나중에 노드를 추가할 때는 새로 만듭니다.
sudo kubeadm token create --ttl 30m --print-join-command
etcd 멤버가 3개인지 확인합니다.
sudo crictl exec $(sudo crictl ps --name etcd -q) etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key member list -w table
6. Cilium 설치
이 단계 전까지 노드는 CNI가 없어 NotReady입니다.
# cilium-values.yaml
kubeProxyReplacement: true
k8sServiceHost: 10.10.0.20 # kube-proxy가 없으므로 API 서버 주소를 직접 알려 준다
k8sServicePort: 6443
ipam:
mode: kubernetes
helm install cilium cilium --repo https://helm.cilium.io --version 1.20.2 \
-n kube-system -f cilium-values.yaml --wait --timeout 10m
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status
kube-proxy가 없으면 Service IP 트래픽은 Cilium이 처리합니다. 그런데 Cilium이 시작되는 시점에는 이 처리가 아직 준비되지 않아, Cilium은 kubernetes.default Service IP로 API 서버에 접속할 수 없습니다. 그래서 k8sServiceHost에 가상 IP를 직접 적습니다. 상태에 KubeProxyReplacement: True와 노드 수만큼의 Cluster health가 나오면 정상입니다.
7. 클러스터 검증
| 항목 | 방법 | 결과 |
| 노드 | kubectl get nodes | 5대 모두 Ready |
| 시스템 Pod | kubectl get pods -A | 모두 Running, kube-proxy 없음 |
| Pod 네트워크 | 워커 2대에 nginx를 띄우고 Service로 5번 호출 | 모두 200 |
| DNS | kubernetes.default.svc.cluster.local 조회 | 10.96.0.1 |
| 가상 IP 전환 | VIP를 가진 마스터에서 kube-vip 매니페스트를 다른 폴더로 옮긴 뒤 매초 /readyz 호출 | 다른 마스터로 넘어감, 실패 0번 |
가상 IP 전환은 마스터가 실제로 멈추기 전에 꼭 시험해 봅니다.
# VIP를 가진 마스터에서
sudo mv /etc/kubernetes/manifests/kube-vip.yaml /tmp/
# 다른 PC에서
for i in $(seq 30); do curl -sk -o /dev/null -w '%{http_code}\n' https://10.10.0.20:6443/readyz; sleep 1; done
# 확인 후 되돌림
sudo mv /tmp/kube-vip.yaml /etc/kubernetes/manifests/
8. MetalLB
클라우드가 아닌 환경에서는 LoadBalancer Service에 IP를 줄 주체가 없어 MetalLB를 씁니다.
# metallb-values.yaml (0.16부터 BGP용 frr-k8s가 기본으로 켜지므로 L2만 쓸 때는 끈다)
frrk8s:
enabled: false
---
# metallb-pool.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: lan-pool
namespace: metallb-system
spec:
addresses:
- 10.10.0.240-10.10.0.249
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: lan-l2
namespace: metallb-system
spec:
ipAddressPools:
- lan-pool
interfaces:
- ens192
helm install metallb metallb --repo https://metallb.github.io/metallb --version 0.16.1 \
-n metallb-system --create-namespace -f metallb-values.yaml --wait
kubectl apply -f metallb-pool.yaml
kubeadm이 마스터에 node.kubernetes.io/exclude-from-external-load-balancers 라벨을 붙이므로, IP 광고는 워커만 합니다.
9. Gateway API
Cilium은 Gateway API를 직접 구현하므로 별도 ingress 컨트롤러가 필요 없습니다. CRD를 먼저 설치한 뒤 Cilium에서 기능을 켭니다.
B=https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard
for c in gatewayclasses gateways httproutes referencegrants grpcroutes backendtlspolicies tlsroutes; do
kubectl apply --server-side -f $B/gateway.networking.k8s.io_$c.yaml
done
# cilium-values.yaml에 추가
gatewayAPI:
enabled: true
helm upgrade cilium cilium --repo https://helm.cilium.io --version 1.20.2 -n kube-system -f cilium-values.yaml --wait
kubectl -n kube-system rollout restart deploy/cilium-operator ds/cilium
kubectl get gatewayclass # cilium, ACCEPTED=True
Cilium 1.20은 standard 채널의 TLSRoute CRD까지 있어야 Gateway 컨트롤러가 시작됩니다. Gateway를 만들면 Cilium이 cilium-gateway-<이름> LoadBalancer Service를 만들고 MetalLB가 IP를 줍니다. 특정 IP는 Gateway의 spec.infrastructure.annotations에 metallb.io/loadBalancerIPs로 지정합니다.
서비스는 포트가 아니라 호스트 이름으로 나누는 것이 좋습니다. Cilium은 호스트 이름 없는 HTTP 리스너를 포트와 상관없이 하나의 라우팅 표로 합치므로 서비스가 섞일 수 있습니다.
10. cert-manager
# cert-manager-values.yaml
crds:
enabled: true
config:
apiVersion: controller.config.cert-manager.io/v1alpha1
kind: ControllerConfiguration
enableGatewayAPI: true
helm install cert-manager cert-manager --repo https://charts.jetstack.io --version v1.21.2 \
-n cert-manager --create-namespace -f cert-manager-values.yaml --wait
Gateway API CRD가 먼저 있어야 cert-manager의 Gateway 연동(gateway-shim)이 켜집니다. 순서가 바뀌면 cert-manager를 다시 시작해야 합니다. 연동이 켜지면 Gateway에 cert-manager.io/cluster-issuer 어노테이션만 붙여도 HTTPS 리스너의 certificateRefs에 맞춰 인증서를 만듭니다.
11. Argo CD
# argocd-values.yaml
global:
domain: argocd.homelab.example
configs:
params:
server.insecure: true # TLS는 Gateway에서 끝낸다
dex:
enabled: false
helm install argocd argo-cd --repo https://argoproj.github.io/argo-helm --version 10.10.3 \
-n argocd --create-namespace -f argocd-values.yaml --wait
server.insecure를 켜지 않으면 Gateway가 HTTP로 넘긴 요청을 argocd-server가 다시 HTTPS로 리다이렉트해 리다이렉트가 끝없이 반복됩니다. Gateway에서 TLS를 종료하면 argocd-server는 HTTP로 요청을 처리합니다. 초기 admin 비밀번호는 argocd-initial-admin-secret에 있습니다.
12. 정리
- 마스터를 여러 대 두려면 controlPlaneEndpoint를 처음부터 가상 IP나 DNS 이름으로 지정합니다.
- kube-vip는 첫 init 동안만 super-admin.conf를 쓰고, init 뒤에는 admin.conf로 되돌립니다.
- kube-proxy를 빼고 Cilium을 쓸 때는 k8sServiceHost에 API 주소를 직접 적습니다.
- MetalLB 0.16은 L2만 쓸 때 frr-k8s를 끄고, Gateway API는 CRD를 먼저 설치한 뒤 Cilium과 cert-manager를 설정합니다.
- 설치 뒤에는 가상 IP 전환과 Pod 네트워크를 직접 시험해 봅니다.
- 구성 요소는 서로 지원하는 버전 조합인지 문서로 확인하고 설치합니다.
참고
- Kubernetes Docs - Creating Highly Available Clusters with kubeadm
- Kubernetes Docs - Installing kubeadm
- kube-vip Docs - Static Pods
- Cilium Docs - Kubernetes Without kube-proxy
- Cilium Docs - Gateway API Support
- MetalLB Docs - Installation
- cert-manager Docs - Gateway API
- Argo CD Docs - Ingress Configuration
'DevOps > Kubernetes' 카테고리의 다른 글
| [Kubernetes] Helm 차트 정리 (0) | 2026.10.01 |
|---|---|
| [Kubernetes] 리소스 requests, limits와 HPA 정리 (0) | 2026.09.28 |
| [Kubernetes] Volume, PV, PVC, StorageClass 정리 (0) | 2026.09.28 |
| [Kubernetes] ConfigMap과 Secret 정리 (0) | 2026.09.28 |
| [Kubernetes] Service와 Ingress, Gateway API 정리 (0) | 2026.09.28 |