Kubernetes Practice 03

kubeadm init과 join: 클러스터 심장과 팔다리 연결하기

init과 join 명령을 따라 치면 클러스터가 되는 것처럼 보이지만, control-plane이 무엇을 열고 worker가 무엇을 받아 들어오는지 감이 흐릿하다.

근거 · Kubernetes 교안 p36-p44

kubeadm init과 join: 클러스터 심장과 팔다리 연결하기 대표 이미지

1장: init 명령, 클러스터의 심장을 만들다

솔라는 자신의 터미널 창과 인터넷의 튜토리얼 문서를 번갈아 보며 숨을 골랐다. 세 개의 가상 머신이 준비되었고, 각각의 터미널 창이 화면에 나란히 떠 있었다. 이제 막 쿠버네티스 설치의 첫 단계를 통과한 참이었다.

“좋아, 모든 노드에 컨테이너 런타임이랑 kubeadm, kubelet 다 설치했고… 다음은… ‘마스터 노드에서 kubeadm init을 실행합니다’.”

솔라는 튜토리얼의 문장을 소리 내어 읽으며, 세 개의 터미널 창 중 ‘k8s-master’라고 이름 붙인 창에 명령어를 망설임 없이 타이핑했다.

sudo kubeadm init

엔터 키를 누르자, 하얀 글자들이 화면을 빠르게 채우기 시작했다. 솔라의 시선은 이미 명령어 다음 줄, 즉 ‘Join your nodes’라고 쓰인 다음 단계로 향해 있었다. init은 그냥 시작을 알리는 신호 같은 것일 테니, 이 명령이 끝나면 나오는 join 명령어를 복사해서 나머지 두 노드에 붙여넣기만 하면 클러스터가 완성될 터였다. 단순한 절차였다.

그때 옆에서 지켜보던 루나가 조용히 솔라의 어깨를 짚었다.

“잠깐, 솔라. 너무 빨리 지나가지 마.”

“응? 언니. 이제 거의 다 됐어. 저기 join 명령어 보이지? 저것만 나머지 노드에서 실행하면…”

“명령어가 뭘 했는지도 봐야지. 방금 네가 깨운 게 뭔지 알아야 다음 단계도 의미가 있잖아.”

루나의 말에 솔라는 마지못해 스크롤을 위로 올렸다. [init]으로 시작해서 수많은 검증과 설정 과정을 거친 로그가 화면 가득했다. 솔라의 눈에는 그저 복잡한 컴퓨터의 독백처럼 보였다.

“음… 그냥 초기화한 거 아냐? 이름도 init인데.”

“정말 그럴까?” 루나는 턱짓으로 화면의 특정 부분을 가리켰다. “저기, ‘Control-plane components’라고 쓰인 부분 좀 봐. 네가 실행한 명령어가 뭘 ‘만들었는지’ 직접 알려주고 있어.”

솔라는 루나가 가리킨 곳으로 시선을 옮겼다. [control-plane]이라는 표식 아래로 etcd, kube-apiserver, kube-scheduler, kube-controller-manager 같은 낯선 이름들이 줄지어 나타났다가 사라진 흔적이 보였다. 마치 무대 뒤에서 분주하게 움직이는 스태프들의 명단 같았다.

“컨트롤 플레인… 클러스터의 뇌라고 들었던 것 같아. 그럼 init은 그냥 텅 빈 컴퓨터 한 대를 마스터 노드로 ‘지정’하는 게 아니라, 그 안에 뇌의 각 부분을 실제로 ‘만들어’ 넣는 거였구나.”

“정확해. 빈 땅에 건물의 뼈대를 올리는 작업이지. 쿠버네티스라는 시스템을 지휘하고 통제할 심장, 즉 컨트롤 플레인을 바로 이 노드 위에 방금 막 탄생시킨 거야.”

솔라는 다시 화면을 천천히 아래로 내렸다. 복잡하기만 하던 출력 결과가 다르게 보이기 시작했다. 단순한 로그가 아니라, 클러스터의 심장이 만들어지는 과정을 중계하는 메시지였다.

스크롤이 거의 끝에 다다랐을 때, 초록색으로 강조된 상자가 눈에 띄었다.

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a regular user:

  mkdir -p $HOME/.kube
  sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
  sudo chown $(id -u):$(id -g) $HOME/.kube/config

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
...

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.0.10.11:6443 --token abcdef.0123456789abcdef \
    --discovery-token-ca-cert-hash sha256:1234...

“아…!” 솔라가 짧은 탄성을 뱉었다. “이건 그냥 다음 단계 안내문이 아니었네. 방금 만들어진 컨트롤 플레인이 나한테 말을 거는 거였어.”

솔라의 목소리에는 흥분이 묻어났다.

“‘제가 성공적으로 태어났습니다! 저와 대화하고 싶으시면 이 열쇠(kubeconfig)를 복사해서 쓰세요. 그리고 아직은 노드들끼리 소통할 길이 없으니 네트워크도 만들어 주셔야 해요. 마지막으로, 다른 일꾼들을 데려오고 싶으면 이 비밀 초대장(kubeadm join ...)을 보여주세요.’ 하고.”

솔라는 방금 자신이 내뱉은 말을 되새기며 고개를 끄덕였다. kubeadm init. 그것은 단순한 시작 명령어가 아니었다. 클러스터라는 유기체의 심장을 만들고, 외부 세계와 소통할 방법과 다른 구성원을 받아들일 절차까지 스스로 마련해서 사용자에게 건네주는, 모든 것의 시작점이었다. 심장이 막 뛰기 시작했지만, 아직 몸 전체에 피를 보내거나 외부의 명령을 제대로 들을 준비는 마치지 못한 상태.

“알겠어. init은 심장을 만드는 거였어. 그럼 이제 이 심장이 제대로 뛰게 하려면, 저기서 시키는 대로 열쇠도 설정하고, 네트워크라는 혈관도 연결해 줘야겠네.”

솔라는 더 이상 다음 단계로 급하게 넘어가려 하지 않았다. 대신 mkdir -p $HOME/.kube라는 첫 번째 지시 사항을 천천히 자신의 터미널에 입력하기 시작했다. 방금 태어난 클러스터의 첫 번째 요구에 응답하는 일이었다.

2장: kubeconfig와 네트워크, 클러스터에 숨을 불어넣다

솔라는 kubeadm init의 마지막 출력문을 보며 만족스러운 미소를 지었다. 클러스터의 심장이 성공적으로 만들어졌다. 이제 막 태어난 컨트롤 플레인과 대화를 나눌 차례였다. ‘클러스터의 상태는 어떤가요?’ 하고 물어볼 참이었다. 그녀는 망설임 없이 kubectl 명령어를 터미널에 입력했다. 클러스터와 소통하는 가장 기본적인 방법이었으니까.

kubectl get nodes

하지만 터미널은 냉정했다. 기대했던 클러스터 노드 목록 대신, 붉은색 에러 메시지가 나타났다.

The connection to the server localhost:8080 was refused - did you specify the right host or port?

“어? 왜 안 되지?” 솔라는 당황해서 중얼거렸다. “분명히 init 성공했다고 나왔는데. 클러스터가 만들어진 거 아니었어?”

옆에서 지켜보던 루나가 조용히 마스터 노드의 터미널을 가리켰다. kubeadm init이 남긴 초록색 안내문이 그대로 남아 있었다.

“솔라, 클러스터가 너한테 편지를 남겼잖아. ‘저와 대화하고 싶으시면 이 열쇠를 복사해서 쓰세요’ 라고. 열쇠 없이 문을 열려고 하니 당연히 거절당하지.”

솔라의 시선이 안내문으로 돌아갔다. ‘To start using your cluster…’로 시작하는 부분이었다. 그제야 솔라는 그 문장들의 진짜 의미를 깨달았다. 그것은 단순한 안내가 아니라, 지금 막 겪은 문제에 대한 해답이었다. kubectl이라는 관리 도구가 클러스터의 API 서버를 찾아가고 인증을 받을 수 있도록, 일종의 출입증이자 주소록인 kubeconfig 파일을 설정하라는 지시였다.

“아… kubectl은 그냥 실행하면 되는 게 아니라, 어느 클러스터에 말을 걸어야 하는지 알려줘야 하는구나.”

솔라는 안내문에 적힌 명령어들을 순서대로 입력했다.

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

마치 클러스터의 API 서버로 통하는 비밀 통로의 열쇠를 자신의 열쇠고리에 거는 듯한 기분이었다. 이제 다시 kubectl이 클러스터를 찾아갈 수 있을 터였다. 솔라는 다시 한번 같은 명령어를 실행했다.

kubectl get nodes

이번에는 성공이었다. 터미널에 노드 정보가 나타났다.

NAME         STATUS     ROLES           AGE   VERSION
k8s-master   NotReady   control-plane   23m   v1.28.2

“됐다! 이제 클러스터가 응답해!”

하지만 솔라의 기쁨은 길지 않았다. 노드의 상태를 나타내는 STATUS 열에 선명하게 찍힌 NotReady라는 글자가 눈에 들어왔다.

“이번엔 또 뭐야? 연결은 됐는데, 준비가 안 됐다고?”

루나가 고개를 끄덕였다. “심장이 뛰기 시작했고, 너와 심장이 대화할 통로도 열었어. 하지만 아직 몸 전체에 피를 보낼 혈관이 없잖아. 클러스터 안의 구성 요소들이 서로 소통할 길이 막혀있는 거야.”

솔라는 init의 출력문 마지막 줄을 떠올렸다. ‘You should now deploy a pod network to the cluster.’ 클러스터에 파드 네트워크를 배포하라는 메시지. 그것이 바로 루나가 말한 ‘혈관’이었다. 솔라는 클러스터의 내부 상태를 더 자세히 들여다보기 위해 시스템 파드들의 상태를 확인했다.

kubectl get pods -n kube-system

결과는 예상대로였다. 핵심적인 역할을 하는 coredns 파드 두 개가 Pending(대기 중) 상태에 머물러 있었다. 있어야 할 곳을 배정받지 못하고 공중에 뜬 상태였다.

“아, DNS 역할을 하는 파드들이 네트워크가 없어서 시작도 못 하고 있구나. 이러니 노드가 준비 상태가 될 리가 없지.”

솔라는 튜토리얼 문서에서 봤던 네트워크 애드온(CNI) 설치 명령어를 찾아 터미널에 입력했다. 클러스터 내부에 복잡한 통신망, 즉 혈관을 깔아주는 작업이었다.

kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.26.1/manifests/calico.yaml

수많은 리소스가 생성되었다는 메시지가 화면을 채웠다. 잠시 후, 솔라는 다시 한번 시스템 파드의 상태를 확인했다.

Pending 상태에 멈춰 있던 coredns 파드들이 이제 Running 상태로 바뀌어 있었다. 심장의 혈관이 연결되자, 가장 중요했던 기능들이 비로소 활성화되기 시작한 것이다. 솔라는 마지막으로 노드의 상태를 다시 확인했다.

kubectl get nodes
NAME         STATUS   ROLES           AGE   VERSION
k8s-master   Ready    control-plane   35m   v1.28.2

NotReady가 사라지고 그 자리에 Ready가 선명하게 빛나고 있었다.

솔라는 깊은숨을 내쉬었다. 이제야 모든 조각이 맞춰졌다. kubeadm init은 클러스터의 심장인 컨트롤 플레인을 만든다. 하지만 그것만으로는 부족했다. kubeconfig 설정으로 관리자인 내가 클러스터와 소통할 ‘외부 연결선’을 확보해야 하고, CNI 설치로 파드들이 서로 소통할 ‘내부 혈관망’을 깔아주어야 비로소 클러스터가 살아 움직이는 하나의 시스템이 되는 것이었다.

“알겠어. 심장을 만들고, 심장과 대화할 길을 열고, 심장이 온몸에 피를 보낼 길까지 뚫어준 거야. 이제 컨트롤 플레인은 정말로 준비가 됐네.”

솔라는 마스터 노드의 터미널을 바라보았다. 심장은 이제 막 완벽하게 뛰기 시작했다. 이제 남은 일은 이 심장에 일감을 받아 처리해 줄 팔다리, 즉 워커 노드들을 연결하는 것이었다.

“그럼… 이제 저기 kubeadm join 초대장을 들고 워커 노드들한테 갈 차례인가?”

3장: join 명령, 워커 노드를 클러스터에 편입시키다

솔라의 화면에는 세 개의 터미널 창이 나란히 떠 있었다. 왼쪽의 마스터 노드 터미널에는 k8s-masterReady 상태로 빛나고 있었다. 이제 막 심장 박동을 시작하고, 혈관까지 연결된 완벽한 상태였다. 하지만 가운데와 오른쪽의 워커 노드 터미널은 아직 그저 텅 빈 대기실일 뿐이었다.

솔라는 마스터 노드의 터미널에 남겨진, init 명령이 뱉어냈던 ‘비밀 초대장’을 다시 한번 확인했다.

kubeadm join 10.0.10.11:6443 --token abcdef.0123456789abcdef \
    --discovery-token-ca-cert-hash sha256:1234...

“좋아. 심장은 준비됐고… 이제 일꾼들을 불러 모을 차례야.”

솔라는 이 긴 명령어를 복사해서 k8s-worker1이라고 이름 붙인 가운데 터미널 창에 붙여넣었다. 그냥 이 명령어를 실행하기만 하면, 이 컴퓨터가 마법처럼 클러스터의 일부가 될 것이라고 생각했다. 단순한 추가 절차. 팔다리를 몸통에 그냥 붙이는 것처럼.

sudo kubeadm join 10.0.10.11:6443 --token ...

엔터 키를 누르자, 워커 노드의 터미널에서도 여러 검증 작업이 빠르게 지나갔다. 그리고 마침내 나타난 성공 메시지.

[preflight] Running pre-flight checks
...
This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.

Run 'kubectl get nodes' on the control-plane to see this node join the cluster.

“됐다! 첫 번째 일꾼 합류!”

솔라는 환호하며 마스터 노드 터미널로 돌아가 재빨리 kubectl get nodes를 입력했다.

NAME          STATUS   ROLES           AGE   VERSION
k8s-master    Ready    control-plane   45m   v1.28.2
k8s-worker1   Ready    <none>          30s   v1.28.2

“와, 바로 Ready 상태가 되네! 이제 정말 클러스터 같아.”

옆에서 지켜보던 루나가 조용히 물었다. “방금 네가 한 일이, 저 워커 노드에게 어떤 의미였을까? 그냥 ‘이제부터 너는 k8s-master의 부하야’ 하고 선언한 걸까?”

“음… 그런 거 아냐?” 솔라는 잠시 망설였다. “join 하라고 했으니까, 그냥 참여한 거지.”

“그렇다면 join 명령어는 왜 그렇게 길고 복잡할까? 그냥 kubeadm join k8s-master 라고 하면 편할 텐데.”

루나의 말에 솔라는 다시 join 명령어를 뜯어보았다. 주소 뒤에 따라붙는 길고 무작위해 보이는 문자열. --token--discovery-token-ca-cert-hash. 전에는 그저 복사해서 붙여넣어야 할 귀찮은 부분이라고만 생각했다.

“토큰은… 초대장 같은 거라고 했지. 그럼 이 해시 값은 뭐지?”

“만약 누군가 중간에 가짜 마스터 행세를 하면서 너의 워커 노드를 납치하려 한다면? 워커 노드는 자기가 합류하려는 컨트롤 플레인이 진짜인지 어떻게 확인할 수 있을까?”

그 순간 솔라의 머릿속에서 모든 조각이 맞춰졌다.

“아! 토큰은 클럽에 들어갈 수 있는 암표고, 해시 값은 문 앞에서 경호원이 대조하는 클럽 주인의 지문 같은 거구나! ‘이 지문을 가진 사람이 운영하는 클럽이 맞는지’ 확인하는 거야. 그래야 엉뚱한 곳에 합류하지 않으니까.”

join은 단순한 참여 선언이 아니었다. 컨트롤 플레인과 워커 노드 사이에 맺어지는 엄격하고 안전한 신뢰 관계의 시작이었다. 워커 노드는 컨트롤 플레인이 init 할 때 생성해준 일회용 암표(토큰)와 주인의 신분증(인증서 해시)을 들고 가서 문을 두드린다. 컨트롤 플레인은 그걸 확인하고 나서야 문을 열어주고, 앞으로 계속 통신할 수 있는 정식 출입증을 발급해 주는 것이었다. 터미널의 Certificate signing request was sent ... and a response was received. 라는 메시지가 바로 그 증거였다.

“그럼 이제 이 워커 노드는 정말로 ‘일할’ 준비가 된 걸까?” 루나가 다음 질문을 던졌다.

“당연하지! 상태도 Ready잖아.” 솔라는 자신 있게 대답했지만, 문득 의심이 들었다. Ready는 정말 ‘일할 준비 완료’라는 뜻일까, 아니면 그냥 ‘연결됨’이라는 뜻일까.

“한번 시켜보면 알겠지.”

솔라는 마스터 노드 터미널에 간단한 Nginx 파드를 실행시키는 명령을 내렸다. 이제 클러스터에는 일감을 처리할 팔다리가 생겼으니, 컨트롤 플레인이 이 새로운 일꾼에게 일을 시킬 것이라 기대했다.

kubectl run nginx-test --image=nginx

파드가 생성되었다는 메시지가 떴다. 솔라는 즉시 파드가 어느 노드에서 실행되고 있는지 확인했다.

kubectl get pods -o wide
NAME         READY   STATUS    RESTARTS   AGE   IP           NODE          NOMINATED NODE   READINESS GATES
nginx-test   1/1     Running   0          15s   192.168.1.5   k8s-worker1   <none>           <none>

결과는 명확했다. NODE 열에 k8s-worker1이 선명하게 찍혀 있었다. 컨트롤 플레인이라는 심장이 nginx-test라는 일감을 k8s-worker1이라는 팔에게 정확히 전달한 것이다.

솔라는 깊은 만족감과 함께 마지막 남은 k8s-worker2 터미널을 바라보았다. 이제 kubeadm join 명령어는 더 이상 복잡하고 의미 없는 암호문이 아니었다. 클러스터를 완성하는 마지막 열쇠였다.

솔라는 더 이상 망설이지 않았다. 남은 워커 노드에 join 명령을 실행하며 혼잣말처럼 과정을 정리했다.

init으로 클러스터의 심장을 만들고, kubeconfig로 심장과 대화할 길을 열고, CNI로 혈관을 연결했어. 이제 join 명령, 즉 신뢰할 수 있는 초대장을 들고 마지막 일꾼을 합류시키는 거야. 이 일꾼도 곧 Ready 상태가 되면, 내 클러스터는 심장 하나에 두 개의 팔다리를 가진 완전한 유기체가 되는 거지.”

터미널에 k8s-worker2Ready 상태로 떠오르는 것을 확인한 솔라는 비로소 미소를 지었다. 그녀는 이제 단순히 명령어를 따라 치는 사람이 아니었다. 각 명령이 클러스터라는 유기체에 어떤 생명력을 불어넣는지, 그 연결의 의미가 무엇인지 이해하는 엔지니어가 되어 있었다. 눈앞의 세 노드는 더 이상 흩어져 있는 가상 머신이 아니라, 하나의 목표를 위해 함께 뛰는, 그녀가 직접 창조한 작은 우주였다.