kubeadm으로 쿠버네티스 클러스터에 마스터·워커 노드 추가하기

3/27/2025 ·impact

kubeadm으로 쿠버네티스 클러스터에 마스터·워커 노드 추가하기

최근 쿠버네티스 클러스터를 확장할 일이 생겼습니다. 새 노드를 붙이면서 명령어를 하나씩 다시 찾느라 시간을 꽤 썼고, 다음엔 같은 고생을 반복하지 말자는 생각으로 절차를 정리해 뒀습니다. 워커 노드는 join 명령 한 줄이면 끝납니다. 마스터(컨트롤 플레인) 노드는 인증서를 함께 넘겨야 해서 앞에 한 단계가 더 붙습니다.

새 노드에 미리 갖춰 둘 것

join 명령 자체는 짧습니다. 그런데 새 노드 준비가 안 되어 있으면 거기서 멈춥니다. 쿠버네티스 kubeadm 설치 문서가 요구하는 조건부터 훑고 갑니다.

  • 메모리 2GB 이상, 컨트롤 플레인으로 쓸 머신은 CPU 2코어 이상
  • 노드마다 유일한 호스트명·MAC 주소·product_uuid — 값은 sudo cat /sys/class/dmi/id/product_uuid로 확인 (가상 머신은 값이 겹치기도 하고, 겹치면 설치 실패)
  • 컨테이너 런타임, kubelet, kubeadm 설치
  • swap 비활성화, 아니면 kubelet 쪽 swap 허용 설정

swap은 대충 넘기면 kubelet이 아예 안 뜹니다. 문서 표현으로는 "노드에 swap 메모리가 감지되면 kubelet이 기동에 실패하는 것이 기본 동작"입니다. sudo swapoff -a로 끄고 재부팅 뒤에도 유지되도록 /etc/fstab과 systemd.swap을 정리하든지, kubelet 설정에 failSwapOn: false를 넣어 허용하든지 둘 중 하나죠.

허용 쪽을 골랐다면 kubelet 버전을 먼저 확인하세요. 1.30 이상이라면 Linux 노드 swap 동작 문서 기준으로 swapBehavior 기본값이 NoSwap이고, 이 값을 건드리지 않는 한 파드는 swap을 쓰지 못합니다. 그 아래 버전은 얘기가 다릅니다. Kubernetes v1.30 변경 예고는 노드 안정성을 해칠 수 있는 UnlimitedSwap 동작이 v1.30에서 제거되고, 기본 동작이 UnlimitedSwap이 아닌 NoSwap으로 바뀐다고 못 박아 뒀습니다. 뒤집어 말하면 1.30 이전에는 버전에 따라 NodeSwap 피처 게이트 상태와 켰을 때의 기본 동작이 지금과 달랐다는 뜻입니다. 아래 예제 같은 1.26대 클러스터에서 failSwapOn: false만 넣고 "워크로드는 swap을 안 쓴다"고 가정하면 안 됩니다. 손에 든 버전의 피처 게이트 상태를 직접 확인하세요.

IPv4 패킷 포워딩도 켜 둡니다. 재부팅 뒤에도 살아 있어야 하니 sysctl.d에 파일로 떨궈 두는 편이 뒤탈이 없습니다.

cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.ipv4.ip_forward = 1
EOF

sudo sysctl --system

cgroup 드라이버는 kubelet과 컨테이너 런타임이 같은 값을 써야 합니다. 컨테이너 런타임 문서는 이 조건을 critical이라고 표현하면서, kubelet과 런타임은 cgroupfs를 쓰는데 나머지 프로세스는 systemd를 쓰는 노드가 자원 압박 상황에서 불안정해진다고 설명합니다. systemd가 init인 배포판이라면 양쪽 다 systemd로 맞추는 쪽이 권장 구성이고, 아래 예제 설정도 그렇게 잡혀 있습니다.

버전도 미리 맞춥니다. 버전 스큐 정책은 kubelet이 kube-apiserver보다 최대 세 개 마이너 버전까지 낮아도 된다고 규정하고, 괄호로 단서를 하나 답니다. kubelet이 1.25보다 낮은 버전이면 허용 폭이 두 개로 줄어듭니다.

열려 있어야 하는 포트

포트와 프로토콜 문서가 정리한 기본값입니다.

대상포트용도
컨트롤 플레인6443/TCP쿠버네티스 API 서버
컨트롤 플레인2379-2380/TCPetcd 서버 클라이언트 API
컨트롤 플레인10257, 10259/TCPkube-controller-manager, kube-scheduler
모든 노드10250/TCPkubelet API
워커10256/TCPkube-proxy
워커30000-32767/TCP·UDPNodePort 서비스

NodePort 범위는 문서에도 TCP 행과 UDP 행이 따로 있습니다. UDP 서비스를 안 쓴다면 TCP만 열어도 그만이지만, 열어야 할 때 빠뜨리기 쉬운 자리라 표에 같이 적어 뒀습니다. etcd는 외부 호스트나 다른 포트로 옮길 수 있으니 기본 포트를 바꿔 뒀다면 바꾼 쪽을 열어야 하고, NodePort 범위를 조정한 클러스터도 마찬가지입니다. 쓰는 파드 네트워크 플러그인이 자기 포트를 따로 요구하기도 하고요. 새 노드에서 컨트롤 플레인 엔드포인트가 실제로 열려 있는지는 nc 한 줄로 확인됩니다.

nc 10.0.10.100 6443 -zv -w 2

기존 클러스터 쪽도 한 번 들여다볼 만합니다. PKI 인증서와 요구 조건 문서는 "kubeadm이 생성한 클라이언트 인증서는 1년 뒤 만료된다"고 명시합니다. 오래 굴린 클러스터라면 노드를 붙이기 전에 kubeadm certs check-expiration으로 컨트롤 플레인 인증서가 살아 있는지 확인하는 편이 낫습니다. 만료된 인증서 위에서 join을 시도해 봐야 원인 찾느라 시간만 버립니다.

마스터 노드 추가 (1) kubeadm-config.yaml

클러스터를 처음 구성할 때 쓴 kubeadm-config.yaml을 그대로 재사용합니다. API 서버 엔드포인트, 파드·서비스 대역, 이미지 저장소처럼 클러스터 전체가 공유하는 값이 여기 들어 있습니다.

apiVersion: kubeadm.k8s.io/v1beta2
kind: InitConfiguration
localAPIEndpoint:
 advertiseAddress: 10.0.10.101
 bindPort: 6443
nodeRegistration:
 criSocket: /var/run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta2
kind: ClusterConfiguration
kubernetesVersion: 1.26.3
controlPlaneEndpoint: 10.0.10.100:6443
imageRepository: registry.k8s.io
networking:
 serviceSubnet: 10.96.0.0/12
 podSubnet: 192.168.0.0/16
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd

controlPlaneEndpoint는 컨트롤 플레인 노드 전체가 공유하는 주소입니다. kubeadm 문서는 나중에 HA로 올릴 생각이 있다면 kubeadm init 단계에서 --control-plane-endpoint로 이 공유 엔드포인트를 잡아 두라고 권하고, 값으로는 DNS 이름이나 로드밸런서 주소를 씁니다. 노드 한 대의 주소인 advertiseAddress와는 성격이 다릅니다. 마스터를 늘리기 전에 로드밸런서가 새 노드까지 바라보게 되어 있는지 먼저 손봐야 합니다.

podSubnet은 쓰려는 파드 네트워크 애드온에 맞춥니다. 문서도 선택한 서드파티 제공자에 따라 --pod-network-cidr를 제공자별 값으로 지정해야 할 수 있다고 안내합니다.

설정 파일의 apiVersion에도 유효기간이 있습니다. 위 예제의 v1beta2kubeadm 설정 API 문서가 "kubeadm v1.27.x 이상은 v1beta2와 그 이전 API를 더 이상 지원하지 않는다"고 적어 둔 대상입니다. 같은 문서는 v1beta3도 v1beta4로 대체되어 1.34 이후 릴리스에서 제거될 예정이라고 예고해 뒀습니다. 예제 같은 1.26대 클러스터라면 그대로 써도 되지만 그 위 버전에서는 옮겨야 하니, 손에 든 kubeadm 기준으로 확인하세요.

마스터 노드 추가 (2) 인증서 키와 join 명령어

컨트롤 플레인 노드끼리는 CA 키를 포함한 인증서 묶음을 공유해야 합니다. kubeadm은 이 인증서를 클러스터 안의 kubeadm-certs 시크릿에 암호화해 올려 두고, 그걸 풀 열쇠를 인증서 키로 내줍니다. kubeadm init 레퍼런스--certificate-key 설명에 따르면 값 자체는 16진수로 인코딩된 32바이트 AES 키입니다. 기존 마스터 노드에서 실행합니다.

kubeadm init phase upload-certs --upload-certs --config=kubeadm-config.yaml

실행이 끝나면 마지막 줄에 인증서 키가 찍혀 나옵니다. 이 값을 다음 단계에서 그대로 씁니다.

[upload-certs] Using certificate key:
3f92ac5bd1230f44e9e8cbcc65a3e8be9a9d1a501f032bde881a7a2ff21ef8e1

여기서 --config는 필수가 아닙니다. kubeadm은 kubeadm init 때 받은 설정을 kube-system 네임스페이스의 kubeadm-config ConfigMap에 저장해 두고 join·reset·upgrade에서 이 값을 읽습니다. 실제로 HA 클러스터 구성 문서sudo kubeadm init phase upload-certs --upload-certs처럼 플래그 없이 실행합니다. 원본 파일이 손에 있으면 위처럼 명시하고, 없으면 빼고 돌리면 됩니다.

이제 이 키를 인자로 넘겨 join 명령어를 뽑습니다. 토큰 생성과 명령어 출력이 한 번에 끝납니다.

kubeadm token create --certificate-key 3f92ac5bd1230f44e9e8cbcc65a3e8be9a9d1a501f032bde881a7a2ff21ef8e1 --print-join-command

받아 든 결과는 이런 모양입니다. 워커용 명령에 옵션 두 개가 더 붙은 형태죠.

kubeadm join 10.0.10.100:6443 --token x7yz12.mj18slwz58r2t8d0 \
 --discovery-token-ca-cert-hash sha256:5f42b9ef35dabc8cce6c1c632bf6e31f9c3aabc90df07d630a0b4c1d2f8b1234 \
 --control-plane --certificate-key 3f92ac5bd1230f44e9e8cbcc65a3e8be9a9d1a501f032bde881a7a2ff21ef8e1

새로 추가할 마스터 노드에서 이 명령을 실행하면 컨트롤 플레인으로 조인됩니다. --control-plane은 이 노드에 컨트롤 플레인 인스턴스를 새로 만들라는 뜻이고, --certificate-key는 시크릿에 올려 둔 인증서를 복호화하라는 뜻입니다. 네트워크 인터페이스가 여러 개인 노드라면 --apiserver-advertise-address를 같이 주세요. kubeadm join 레퍼런스는 이 값을 설정하지 않으면 기본 네트워크 인터페이스가 쓰인다고 설명하는데, 그게 의도한 주소와 다를 수 있습니다.

컨트롤 플레인을 몇 대까지 늘릴지는 etcd를 어디에 두느냐로 갈립니다. HA 토폴로지 문서는 스택형 etcd라면 컨트롤 플레인 노드 최소 3대, 외부 etcd라면 컨트롤 플레인 3대와 etcd 3대를 HA 최소 구성으로 제시합니다. 스택형에서는 노드 한 대가 내려갈 때 etcd 멤버와 컨트롤 플레인 인스턴스가 함께 사라져 이중화가 같이 깨진다는 점도 같은 문서에 적혀 있습니다. Raft 멤버 하나가 데이터를 잃었을 때 어떻게 수습하는지는, etcd는 아니지만 같은 Raft 기반이라 참고가 되는 EKS Vault Raft 장애 복구: Pod와 PVC 삭제 후 클러스터 재구성에 사례로 적어 뒀습니다.

워커 노드 추가하기

워커 노드는 CA 키를 비롯한 컨트롤 플레인 인증서 묶음을 넘겨받지 않으니 인증서 키가 필요 없습니다. 대신 조인 과정에서 TLS 부트스트랩이 돕니다. 공유 토큰으로 API 서버에 임시 인증한 뒤 CSR을 올리면 컨트롤 플레인이 이를 서명해 주고, kubeadm이 그렇게 받은 신원으로 kubelet을 설정합니다. 이 자동 승인은 kubeadm이 알아서 깔아 둡니다. kubeadm 구현 상세 문서를 보면 system:bootstrappers:kubeadm:default-node-token 그룹에 system:certificates.k8s.io:certificatesigningrequests:nodeclient 롤을 묶는 kubeadm:node-autoapprove-bootstrap ClusterRoleBinding을 만들어, 부트스트랩 토큰의 CSR이 csrapprover 컨트롤러에게 자동 승인되도록 보장한다고 되어 있습니다. 그래서 기존 마스터에서 토큰만 새로 뽑으면 끝입니다.

kubeadm token create --print-join-command

출력은 워커용이라 훨씬 짧습니다. 인증서 키도, --control-plane도 붙지 않습니다.

kubeadm join 10.0.10.100:6443 --token a1b2c3.d4e5f6g7h8i9j0kl \
 --discovery-token-ca-cert-hash sha256:5f42b9ef35dabc8cce6c1c632bf6e31f9c3aabc90df07d630a0b4c1d2f8b1234

이 명령을 추가하려는 워커 노드에서 실행하면 노드가 등록됩니다. --discovery-token-ca-cert-hash는 컨트롤 플레인이 내미는 루트 CA 공개키를 검증하는 값입니다. kubeadm join 레퍼런스 설명으로는 <hash-type>:<hex-encoded-value> 형식이고 지원하는 해시 타입은 sha256, 해시 대상은 RFC7469의 SPKI(Subject Public Key Info) 객체 바이트입니다. 같은 문서가 이 플래그를 여러 번 반복해 공개키를 둘 이상 허용할 수 있다는 점도 적어 뒀습니다. --print-join-command가 해시까지 계산해 뱉으니 손으로 조합하지 말고 나온 문장을 그대로 붙여 넣으세요.

토큰은 24시간, 인증서 키는 2시간

두 값의 수명은 다릅니다. 한 덩어리로 기억했다가 조인 도중에 한쪽만 죽어 있으면 원인을 엉뚱한 데서 찾게 됩니다. 부트스트랩 토큰부터 보면, kubeadm token 레퍼런스에서 --ttl 기본값은 24h0m0s입니다. --ttl 1h처럼 줄일 수 있고, 0을 주면 만료되지 않습니다. 살아 있는 토큰 목록은 kubeadm token list가 뽑아 줍니다.

인증서 키는 플래그로 늘리는 값이 아닙니다. HA 클러스터 구성 문서는 업로드된 인증서가 안전장치로 2시간 뒤 삭제되며, 필요하면 kubeadm init phase upload-certs로 다시 올리라고 못 박아 뒀습니다. 마스터 노드를 붙이다 두 시간을 넘겼다면 upload-certs부터 다시 돌려 새 키를 받으면 됩니다. join 명령어에는 클러스터에 노드를 붙일 자격이 통째로 들어 있으니, 채팅방이나 이슈 트래커에 붙여 넣은 채로 방치하지 마세요.

헷갈리기 쉬운 것이 인증서 키와 클러스터 인증서의 구분입니다. 인증서 키는 노드를 추가하는 두 시간짜리 임시 값이고, 컨트롤 플레인과 kubelet이 쓰는 클라이언트 인증서는 발급 시점부터 1년을 갑니다. 후자가 만료돼 겪은 일은 쿠버네티스 인증서 만료 확인과 kubeadm certs renew 갱신 방법에 따로 적어 뒀습니다.

조인 이후 확인

조인이 끝났으면 기존 마스터에서 노드 목록부터 확인합니다. 새 노드가 목록에 나타나야 정상입니다.

kubectl get nodes -o wide

새 노드의 ROLES와 VERSION 표기가 의도한 대로인지 같이 훑습니다. STATUS가 곧바로 Ready로 바뀌지 않는 경우가 있으니 잠깐 기다려도 됩니다. 몇 분이 지나도 그대로면 kube-system 쪽을 뒤질 차례입니다. CNI 데몬셋 파드가 ContainerCreating이나 ImagePullBackOff에 걸려 있는 일이 흔합니다.

kubectl get pods -n kube-system -o wide

컨트롤 플레인을 추가했다면 새 노드에 kube-apiserver, kube-controller-manager, kube-scheduler 파드가 올라왔는지 같이 확인합니다. 예제처럼 스택형 etcd 구성이면 etcd 파드도 같은 노드에 뜨지만, 외부 etcd를 쓰는 클러스터라면 etcd는 별도 호스트에 있으므로 여기서 찾을 이유가 없습니다.

노드 쪽 원인을 캐는 데는 kubelet 로그가 제일 빠릅니다. 조인을 시도한 노드에서 직접 돌리세요.

journalctl -u kubelet -f

조인 실패 원인은 대체로 네 가지입니다. 원인별로 다음 수가 다릅니다.

  • 토큰 만료kubeadm token list에 해당 토큰이 없으면 그겁니다. 기존 마스터에서 kubeadm token create --print-join-command로 다시 뽑아 새 명령어로 조인. 마스터를 붙이는 중이었고 upload-certs로부터 두 시간이 지났다면 kubeadm init phase upload-certs --upload-certs도 다시 돌려 새 인증서 키를 받아야 합니다.
  • cgroup 드라이버 불일치 — kubelet 로그에 런타임 관련 오류가 뜹니다. 노드의 /var/lib/kubelet/config.yaml에 있는 cgroupDriver 값과 컨테이너 런타임 설정(containerd라면 SystemdCgroup)을 같은 쪽으로 맞춘 뒤 kubelet과 런타임을 재시작.
  • 방화벽 — 앞서 쓴 nc로 6443과 10250을 양방향 확인. 컨트롤 플레인을 붙이는 중이라면 2379-2380도 같이 봐야 합니다.
  • 다른 클러스터에 붙었던 서버 재사용/etc/kubernetes 잔재가 남아 있으면 preflight에서 걸립니다. 정리 후 재조인.

마지막 경우의 정리 절차는 kubeadm reset 레퍼런스에 나와 있습니다. reset은 kubeadm init이나 kubeadm join이 이 호스트에 만든 변경을 best effort로 되돌리는 명령인데, 문서가 직접 밝히듯 CNI 설정과 iptables·nftables·IPVS 규칙까지 지워 주지는 않습니다. 그래서 손으로 더 치워야 합니다.

sudo kubeadm reset -f --cleanup-tmp-dir
sudo rm -rf /etc/cni/net.d
rm -rf $HOME/.kube

kube-proxy가 깔아 둔 규칙까지 걷어내려면 문서가 안내하는 kube-proxy --cleanup을 별도로 돌리면 됩니다. 여기까지 하고 다시 join 명령을 실행하세요.

노드를 늘릴 때 무엇부터 정하나

명령은 노드 역할이 정한다. 컨트롤 플레인이면 업로드한 인증서 키와 --control-plane이 함께 필요하고, 워커면 --print-join-command가 뽑아 준 한 줄로 끝난다. 인증서 키는 2시간, 토큰은 기본 24시간이라 어제 받은 명령은 그대로 쓰지 못한다.

조인이 막히면 명령보다 환경을 먼저 본다. cgroup 드라이버가 어긋났거나 6443·10250이 닫혀 있으면 명령을 다시 붙여 넣어도 결과는 같다.

다음 노드는 journalctl -u kubelet을 띄운 채 조인하고, 끝나면 kubectl get nodes로 확인하자.