kubeadm은 Kubernetes가 공식으로 제공하는 클러스터 설치 도구로, 노드를 준비한 뒤 init과 join 두 명령으로 클러스터를 만듭니다. 마스터 노드를 여러 대 두려면 처음부터 마스터들이 함께 쓸 API 주소(가상 IP나 DNS 이름)를 정해야 하고, 네트워크(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) |
버전은 나온 지 2주 이상 지난 릴리스 중에서, 구성 요소끼리 공식 문서상 지원하는 조합으로 골랐습니다. 처음에는 Kubernetes 1.37을 설치했지만, 정식 출시된 Cilium 1.20이 Kubernetes 1.36까지만 지원해서 클러스터를 만들기 전에 1.36.5로 내렸습니다.
2. 노드 준비
노드는 Ubuntu autoinstall로 설치하면서 스왑을 끄고(storage.swap.size: 0), SSH는 공개 키 인증만 허용했습니다. kubelet은 기본적으로 스왑이 켜져 있으면 시작하지 않으므로 설치 단계에서 끄면 편합니다.
설치가 끝나면 모든 노드에서 커널 모듈과 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 | Ubuntu 24.04는 cgroup v2와 systemd를 쓰므로 kubelet과 containerd의 cgroup 드라이버를 systemd로 맞춤 |
| cri-tools | 이번에 설치한 kubeadm 패키지는 crictl을 의존성으로 함께 설치하지 않았고, kubeadm 사전 점검이 crictl을 찾으므로 따로 설치 |
| apt-mark hold | apt upgrade로 kubelet 버전이 의도치 않게 바뀌지 않게 고정 |
설치 후 다음 명령으로 확인합니다. kubelet은 이 단계에서 설정을 기다리며 멈춰 있거나 재시작을 반복할 수 있고, 이것이 정상입니다. init이나 join으로 설정이 생기면 정상적으로 실행됩니다.
lsmod | grep -E 'overlay|br_netfilter'
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables
grep SystemdCgroup /etc/containerd/config.toml
sudo crictl info >/dev/null && echo ok
3. kube-vip로 API 가상 IP 만들기
kube-vip는 마스터 노드에서 정적 Pod로 실행되며, 마스터끼리 리더를 뽑아 리더가 가상 IP를 ARP로 광고합니다. 별도의 로드밸런서 VM 없이 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가 admin.conf로는 리더 선출을 하지 못해 가상 IP가 활성화되지 않고, init은 API 서버에 접속하지 못해 시간 초과로 실패합니다. 첫 번째 마스터만 init 동안 super-admin.conf를 쓰고, init이 끝나면 되돌립니다.
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에서만 지표를 제공하므로 다른 노드의 Prometheus나 Zabbix에서 수집할 수 없음 |
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가 아니라 0.0.0.0으로 둔 이유는 두 컴포넌트의 liveness probe가 127.0.0.1로 상태를 확인하기 때문입니다. 노드 IP로 바꾸면 probe가 실패해 컴포넌트가 계속 다시 시작됩니다. 이미 만든 클러스터라면 마스터마다 /etc/kubernetes/manifests의 정적 Pod 설정을 한 대씩 고치고, kubeadm-config ConfigMap에도 같은 값을 넣어야 kubeadm upgrade 뒤에도 유지됩니다.
--upload-certs는 마스터용 인증서를 클러스터 안에 암호화해 올려 두는 옵션입니다. 다른 마스터는 join할 때 init에서 출력된 certificate-key로 이 인증서를 받아 갑니다. init 출력에 포함된 토큰과 인증서 키는 노드 추가에 쓰는 인증 정보이므로 기록에 남기지 않습니다.
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 설치
이 단계 전까지 노드는 NotReady입니다. CNI가 없어 Pod 네트워크가 없기 때문입니다.
# 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 없이 설치하면 Cilium은 API 서버에 접속할 때 Service(kubernetes.default)의 IP를 쓸 수 없습니다. Service IP로 들어온 트래픽을 API 서버로 전달하는 일을 원래 kube-proxy가 하는데, 이 역할을 대신할 Cilium 자신이 아직 시작되지 않았기 때문입니다. 그래서 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번 호출 | 5번 모두 200 |
| DNS | kubernetes.default.svc.cluster.local 조회 | 10.96.0.1 |
| 가상 IP 전환 | VIP를 가진 마스터의 kube-vip 매니페스트를 치우고 30초 동안 매초 /readyz 호출 | 다른 마스터로 넘어감, 실패 0번 |
가상 IP 전환 시험은 꼭 해 보기를 권합니다. kube-vip가 실제로 리더를 넘기는지 확인하지 않으면, 마스터 1대가 멈춘 뒤에야 전환에 문제가 있는지 알게 됩니다.
# 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 라벨을 붙입니다. 그래서 MetalLB speaker는 마스터에서도 실행되지만 IP 광고는 워커만 합니다.
9. Gateway API
Cilium은 Gateway API를 직접 구현하므로 별도의 ingress 컨트롤러가 필요 없습니다. Gateway API 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를 만들고, IP는 MetalLB가 줍니다. 특정 IP를 쓰려면 Gateway의 spec.infrastructure.annotations에 metallb.io/loadBalancerIPs를 적으면 그 Service에 그대로 전달됩니다.
서비스를 포트로 나누지 말고 호스트 이름으로 나누는 것을 권합니다. 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를 보고 인증서를 자동으로 만듭니다.
시험용 Gateway로 확인한 결과입니다.
curl --resolve demo.homelab.local:80:10.10.0.240 http://demo.homelab.local/ # 200
curl -k --resolve demo.homelab.local:443:10.10.0.240 https://demo.homelab.local/ # 200
curl -H 'Host: other.local' http://10.10.0.240/ # 404, 등록하지 않은 호스트
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로 리다이렉트해서, 페이지가 리다이렉트를 무한히 반복합니다. TLS 연결을 Gateway에서 종료하는 구성이라면 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 |