Kubernetes Practice 08

Pod 변화 속 안정적인 접속: Kubernetes Service 이해하기

Pod IP로도 curl이 되는데 왜 Service를 만들고 ClusterIP, NodePort, LoadBalancer를 나눠야 하는지 헷갈린다.

근거 · Kubernetes 교안 p84-p97, yaml/svc/*.yaml

Pod 변화 속 안정적인 접속: Kubernetes Service 이해하기 대표 이미지

1장: Pod IP의 불안정성 확인: Service가 필요한 이유

솔라의 손가락이 키보드 위에서 잠시 멈췄다. 검은 터미널 화면에는 방금 실행한 명령어와 그 결과가 선명하게 남아 있었다. 웹서버 역할을 하는 Pod를 하나 띄우고, 그 Pod의 IP 주소로 curl 명령을 날리자 깔끔하게 HTML 코드가 반환되었다. 성공이었다.

솔라는 화면을 가만히 들여다보았다. “언니, 이것 봐. Pod IP로 바로 접속이 되는데? curl 10.244.1.5 하니까 바로 응답이 오잖아.”

솔라의 목소리에는 명쾌한 발견의 기쁨과 함께 미묘한 의문이 섞여 있었다. 분명 조금 전에 읽던 쿠버네티스 문서에서는 Pod에 안정적으로 접속하려면 ‘Service’라는 것을 만들어야 한다고 했다. 하지만 지금 솔라의 눈앞에 펼쳐진 결과는 그 말의 필요성을 무색하게 만들고 있었다.

“Pod IP로도 이렇게 잘 되는데, 왜 굳이 복잡하게 Service라는 걸 또 만들어야 하는지 모르겠어. 그냥 이 IP를 쓰면 안 돼?”

그것은 지극히 합리적인 의문이었다. 작동하는 것을 눈으로 확인했는데, 왜 다른 방법을 찾아야 한단 말인가. 솔라는 이 IP 주소가 애플리케이션의 고정된 주소, 즉 안정적인 엔드포인트가 될 수 있다고 생각했다.

옆에서 솔라의 화면을 함께 보고 있던 루나가 조용히 입을 열었다. “음, 그럴듯한 생각이야. 지금은 그 IP가 잘 작동하는 것처럼 보이지.” 루나는 솔라의 말을 부정하지 않았다. 대신, 터미널 화면 속의 한 줄을 가리켰다.

my-web-deployment-75b8f... 1/1 Running 0 2m15s 10.244.1.5 worker-node1 <none> <none>

“솔라, 그 Pod 이름 뒤에 붙은 문자열 보여? Deployment가 만든 Pod라는 뜻이지. 만약 저 Pod에 문제가 생겨서 갑자기 죽는다면 어떻게 될까?”

“Deployment가 바로 다시 만들겠지. Replica 수를 1로 유지해야 하니까.” 솔라는 예전에 ReplicaSet을 다루던 때를 떠올리며 자신 있게 대답했다.

“맞아. 그럼 한번 직접 확인해 볼까? 그 안정적이라고 생각했던 IP 주소가 정말 계속 같은지.”

루나는 솔라에게 직접 그 상황을 만들어보도록 했다. 솔라는 잠시 망설였지만, 곧 호기심이 이겼다. 먼저, 지금 막 접속에 성공했던 Pod의 IP 주소 10.244.1.5를 메모장에 복사해 두었다. 그리고는 터미널에 명령어를 입력했다.

kubectl delete pod my-web-deployment-75b8f...

pod "my-web-deployment-75b8f..." deleted

Pod가 삭제되었다는 메시지가 떴다. 솔라는 곧바로 다시 Pod 목록을 확인했다.

kubectl get pods -o wide

예상대로였다. Deployment 컨트롤러가 순식간에 새로운 Pod를 만들어내고 있었다. 잠시 ContainerCreating 상태를 거쳐 새로운 Pod가 Running 상태가 되었다. 그런데 솔라의 눈이 화면의 한곳에 고정되었다.

my-web-deployment-5c79c... 1/1 Running 0 5s 10.244.1.6 worker-node1 <none> <none>

“어?”

솔라의 입에서 짧은 탄성이 터져 나왔다. IP 주소가 바뀌어 있었다. 10.244.1.5가 아니라 10.244.1.6이었다. 아주 작은 숫자 하나가 바뀌었을 뿐이지만, 그 의미는 결코 작지 않았다.

“IP가… 바뀌었네.”

“그럼 아까 메모장에 적어뒀던 IP 주소는 어떻게 됐을까?” 루나가 나지막이 물었다.

솔라는 무언가에 홀린 듯, 아까 복사해 두었던 이전 IP 주소로 다시 curl 명령을 실행했다.

curl 10.244.1.5

엔터 키를 눌렀지만 아무런 응답이 없었다. 커서는 다음 줄에서 하염없이 깜빡이기만 했다. 잠시 후, 기다림을 포기한 터미널이 메시지를 뱉어냈다.

curl: (7) Failed to connect to 10.244.1.5 port 80: Connection timed out

연결 실패. 타임아웃. 방금 전까지만 해도 잘 작동하던 주소는 이제 텅 빈 허공을 가리키고 있었다. 솔라는 마지막 확인을 위해 새로 할당된 IP 주소로 curl을 시도했다.

curl 10.244.1.6

이번에는 즉시 익숙한 HTML 코드가 화면에 출력되었다.

솔라는 허탈한 웃음을 지었다. “아… Pod가 다시 생성되면 IP 주소도 완전히 새로 받는구나. 그럼 내가 처음 알았던 IP 주소는 그냥 사라져 버리는 거네.”

“바로 그거야.”

이제 솔라는 ‘Pod IP로도 접속이 되는데?‘라는 처음의 생각이 얼마나 위험한 가정이었는지 깨달았다. Pod의 IP 주소는 영구적인 주소가 아니었다. Pod의 수명 주기에 따라 언제든지 변할 수 있는, 말 그대로 ‘휘발성’을 가진 임시 값이었던 것이다. 만약 다른 서비스가 이 Pod와 통신하기 위해 10.244.1.5라는 주소를 설정 파일에 저장해 두었다면, Pod가 한번 재시작되는 순간 모든 연결은 끊어지고 서비스 전체에 장애가 발생할 터였다.

솔라는 키보드에서 손을 떼고 의자 깊숙이 등을 기댔다. 머릿속이 명쾌해지는 느낌이었다. Service가 왜 필요한지에 대한 첫 번째 단서를 잡았다. Pod IP는 믿을 수 없다. 그것이 결론이었다.

“그럼 언니, 이렇게 계속 바뀌는 IP 주소 말고, Pod가 사라졌다가 다시 생겨나도 절대 변하지 않는, 고정된 접속 포인트를 만들 방법이 필요하다는 거네. 그게 바로 Service의 역할인 거고?”

솔라의 질문은 이제 ‘왜 필요한가’에서 ‘어떻게 가능한가’로 바뀌어 있었다. 불안정한 Pod IP의 세계에서 안정적인 접속의 문을 여는 열쇠는 무엇일까. 그 질문이 솔라와 루나 앞에 새로운 길을 열어주고 있었다.

2장: Service 등장: 라벨 셀렉터로 Pod에 연결

솔라의 질문은 더 이상 허공에 맴돌지 않았다. 그것은 루나의 모니터 위에 구체적인 형태로 떠 있었다. 이전 Pod의 IP 주소를 적어두었던 메모장 창은 닫혔고, 그 자리에는 텅 빈 YAML 파일이 열려 있었다. 파일의 이름은 my-web-service.yaml.

apiVersion: v1
kind: Service
metadata:
  name: my-web-service
spec:
  selector:
    # ???
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 80

파일의 구조는 단순해 보였다. 종류(kind)는 Service. 이름(name)은 my-web-service. 그리고 포트를 정의하는 부분도 있었다. 하지만 솔라의 시선은 물음표 세 개가 적힌 selector 필드에 머물러 있었다. 저것이 바로 언니가 말한 ‘고정된 접속 포인트’를 만드는 열쇠일 터였다.

“언니, 이 Service라는 것도 결국엔, 방금 새로 생긴 Pod의 IP 주소 10.244.1.6을 어딘가에 잘 저장해두는 거 아니야? 그러다가 Pod가 또 죽어서 새 IP를 받으면, 그걸 또 재빨리 알아내서 업데이트하고. 그런 식으로 동작하는 거지?”

솔라의 추측은 그럴듯했다. 변하는 값을 계속 추적해서 최신 상태를 유지하는 것. 그것이 가장 직관적인 해결책처럼 보였다. Service는 똑똑한 주소록 관리자 같은 역할일 거라고 생각했다.

루나는 솔라의 말을 가만히 듣고 있다가, 고개를 저으며 화면의 selector: 부분을 마우스 커서로 가리켰다.

“Service는 Pod의 IP 주소를 전혀 신경 쓰지 않아. 어떤 Pod가 어떤 IP를 가졌는지 추적하지도, 저장하지도 않아.”

“IP를 모른다고? 그럼 어떻게 연결하는데?”

“솔라, 혹시 예전에 ReplicaSet이 자기가 관리할 Pod를 어떻게 찾아내는지 기억나? Pod 이름으로 찾았던가?”

루나의 질문에 솔라는 잠시 생각에 잠겼다. ReplicaSet은 정해진 수의 Pod 복제본을 유지하는 역할을 했다. Pod가 죽으면 새로 만들고, 너무 많으면 없애고… 아! “아니. 이름으로 찾지 않았어. 라벨(label)로 찾았지! app: my-app 같은 라벨이 붙은 Pod가 몇 개인지 세어서 숫자를 맞췄잖아.”

“바로 그거야.” 루나는 고개를 끄덕였다. “Service도 똑같아. IP 주소라는, 언제든 변할 수 있는 신분증 대신, 절대 변하지 않는 이름표, 즉 라벨을 보고 Pod를 찾아내는 거야.”

루나는 my-web-service.yaml 파일의 물음표를 지우고 그 자리에 코드를 입력했다.

# my-web-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-web-service
spec:
  selector:
    app: webserver
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 80

“우리 웹서버 Pod에는 app: webserver라는 라벨이 붙어있어. 이 Service는 쿠버네티스에게 계속해서 이렇게 말하는 것과 같아. ‘app: webserver 라벨을 가진 Pod들을 나에게 연결해 줘. 그 Pod의 실제 IP가 몇 번인지는 상관없어.’ 라고.”

솔라는 그제야 selector의 의미를 어렴풋이 알 것 같았다. Service는 특정 Pod를 지정하는 것이 아니라, 특정 조건을 만족하는 Pod들의 ‘그룹’을 지정하고 있었다.

“자, 그럼 직접 만들어 보자.”

솔라는 터미널에 명령어를 입력했다.

kubectl apply -f my-web-service.yaml

service/my-web-service created

Service가 성공적으로 만들어졌다. 솔라는 곧바로 Service 목록을 확인했다.

kubectl get service

화면에 새로운 항목이 나타났다.

NAMETYPECLUSTER-IPEXTERNAL-IPPORT(S)AGE
kubernetesClusterIP10.96.0.1443/TCP25h
my-web-serviceClusterIP10.103.142.1528080/TCP6s

“와, 언니! my-web-service10.103.142.152라는 새로운 IP가 생겼어! 이게 바로 고정된 주소인 거야?”

“맞아. 저 CLUSTER-IP는 이 Service가 존재하는 한 절대 변하지 않아. 이제 저 IP가 우리의 안정적인 입구가 되어줄 거야.”

솔라는 가슴이 두근거리는 것을 느끼며, 새로운 IP 주소로 curl 명령을 실행했다. Service가 지정한 포트는 8080이었다.

curl 10.103.142.152:8080

결과는 성공이었다. 잠시 후, 익숙한 웹서버의 HTML 코드가 화면에 나타났다.

이제 가장 중요한 순간이 남았다. 과연 이 안정적인 입구는 Pod의 죽음과 재탄생 앞에서도 굳건할까? 솔라는 이전 장에서 했던 실험을 반복하기로 했다. 먼저 현재 실행 중인 웹서버 Pod의 이름을 확인하고, 가차 없이 삭제했다.

kubectl get pods
# NAME                            READY   STATUS    RESTARTS   AGE
# my-web-deployment-5c79c...-xyz   1/1     Running   0          25m

kubectl delete pod my-web-deployment-5c79c...-xyz
# pod "my-web-deployment-5c79c...-xyz" deleted

이전 같았으면 Pod의 IP를 확인하고 접속이 끊기는 것을 불안하게 지켜봤겠지만, 이제 솔라는 달랐다. 그녀는 새로운 Pod가 생성되는 것을 기다리지도, 그 IP를 궁금해하지도 않았다. 대신, 조금의 망설임도 없이 아까와 똑같은 명령어를 다시 입력했다.

curl 10.103.142.152:8080

엔터 키를 누르자, 또다시 성공을 알리는 HTML 코드가 터미널 화면을 채웠다. 뒤편에서는 Deployment가 분주하게 새 Pod를 만들고, 쿠버네티스는 app: webserver 라벨을 가진 새 Pod를 my-web-service에 자동으로 연결해주었을 것이다. 그 모든 과정이 사용자에게는 전혀 보이지 않았다. 오직 변하지 않는 단 하나의 입구, 10.103.142.152:8080만이 존재할 뿐이었다.

솔라는 의자 등받이에 몸을 기댔다. “대박이다… Service는 Pod의 IP를 전혀 몰랐네. 그냥 app: webserver라는 꼬리표만 보고 있었던 거구나. 이게 바로 진짜 ‘추상화’네.”

Pod IP라는 불안정한 대상을 직접 다루는 대신, Service와 라벨 셀렉터는 그 위에 안정적인 계층을 하나 더 만들어주었다. 이제 개발자들은 Pod가 몇 번이고 재시작되더라도, 오직 Service의 고정된 IP만 바라보면 되었다.

문제가 해결되었다는 만족감에 잠긴 솔라의 머리에 새로운 질문이 스쳤다.

“언니, 근데 이 10.103.142.152라는 주소, 정말 편리한데… 내 컴퓨터 웹 브라우저 주소창에 이 주소를 입력해도 접속이 될까? 이 클러스터 안에서만 쓸 수 있는 주소인 건가?”

솔라의 질문은 안정적인 접속의 ‘방법’을 넘어, 그 접속이 허용되는 ‘범위’를 향하고 있었다. 클러스터라는 보이지 않는 벽 안에서만 통하는 문일까, 아니면 벽을 넘어 바깥 세상과도 통하는 문일까? 그 답을 찾기 위해, 그들은 Service의 TYPE 열에 적힌 ClusterIP라는 단어를 다시 보게 될 터였다.

3장: ClusterIP: 클러스터 내부에서의 안정적인 접속

솔라의 손가락이 웹 브라우저의 새로고침 버튼 위를 맴돌았다. 주소창에는 이전 장에서 확인했던 Service의 IP 주소, 10.103.142.152:8080이 선명하게 입력되어 있었다. 하지만 화면에는 익숙한 웹서버의 환영 메시지 대신, 하얀 바탕 위에서 하염없이 돌아가는 로딩 아이콘만이 보일 뿐이었다.

결국 브라우저는 ‘이 사이트에 연결할 수 없음’이라는 차가운 메시지를 띄웠다. 솔라는 미간을 찌푸리며 자신의 노트북 터미널을 열어 다시 한번 curl 명령을 실행했다.

curl 10.103.142.152:8080

결과는 같았다. 커서는 깜빡이기만 할 뿐 아무런 응답이 없었고, 이내 Connection timed out이라는 메시지만이 남았다. 분명히 조금 전, 쿠버네티스 환경에 접속된 터미널에서는 잘 작동하던 주소였다. 솔라는 고개를 갸웃거렸다.

“언니, 이상해. 아까는 분명히 이 IP 주소로 접속이 됐는데, 왜 내 컴퓨터 브라우저나 터미널에서는 안 될까? 이것도 그냥 IP 주소 아니었어?”

솔라는 10.103.142.152라는 숫자들이 자신의 노트북 IP처럼 인터넷 세상 어디에서나 접근할 수 있는 공용 주소일 거라 막연히 기대했다. 하지만 눈앞의 실패는 그 가정이 틀렸음을 보여주고 있었다.

옆에서 지켜보던 루나가 조용히 물었다. “솔라, 그 curl 명령을 어디서 실행했지?”

“내 노트북 터미널에서.”

“그리고 우리가 만든 my-web-service는 어디에 있지?”

“음… 쿠버네티스 클러스터 안에.”

“바로 그거야.” 루나는 모니터 화면에 보이지 않는 선을 그었다. “솔라의 노트북은 ‘클러스터 밖’에 있고, 서비스는 ‘클러스터 안’에 있어. 그 둘은 서로 다른 네트워크 공간이야. 우리가 본 10.103.142.152라는 주소는 클러스터 안에서만 통용되는 특별한 주소거든.”

루나는 Service 목록을 다시 터미널에 띄웠다.

NAMETYPECLUSTER-IPEXTERNAL-IPPORT(S)
my-web-serviceClusterIP10.103.142.1528080/TCP

“저기 TYPE 열에 뭐라고 쓰여있지?”

“ClusterIP…” 솔라가 작은 소리로 단어를 읽었다.

“말 그대로 ‘클러스터 IP’야. 클러스터라는 울타리 안에서만 쓸 수 있는 내부용 IP라는 뜻이지. 우리가 직접 그 울타리 안으로 들어가서 확인해 보면 확실히 알 수 있을 거야.”

루나는 새로운 제안을 했다. 클러스터 안에 잠시 머물다 사라지는 ‘탐사용’ Pod를 하나 띄워서, 그 안에서 my-web-service로 접속을 시도해 보자는 것이었다.

“탐사용 Pod라고?”

“응. 디버깅이나 테스트를 위해 임시로 사용하는 간단한 컨테이너야. 클러스터 안의 주민이 되어서 내부 상황을 살펴보는 거지.”

솔라는 루나의 안내에 따라 터미널에 명령어를 입력했다. busybox라는 가벼운 이미지를 사용해 debug-pod라는 이름의 임시 Pod를 실행하고, 그 Pod의 셸(shell) 환경으로 바로 접속하는 명령어였다.

kubectl run debug-pod --image=busybox -it --rm -- /bin/sh

엔터 키를 누르자 터미널 프롬프트가 $에서 / #로 바뀌었다. 솔라의 작업 공간이 노트북에서 클러스터 안의 debug-pod 내부로 순간 이동한 것이다. 마치 다른 나라에 입국한 것처럼, 그녀는 이제 클러스터의 네트워크 안에 서 있었다.

솔라는 떨리는 마음으로, 아까 노트북에서는 실패했던 바로 그 명령어를 다시 입력했다.

/ # curl 10.103.142.152:8080

이번에는 달랐다. 엔터 키를 누르자마자, 기다렸다는 듯 익숙한 HTML 코드가 화면을 가득 채웠다. 클러스터 밖에서는 묵묵부답이던 주소가, 안으로 들어오자마자 활짝 문을 열어준 것이다.

<h1>Welcome to my web server!</h1>
...

“와! 여기선 바로 되네!”

솔라의 입에서 감탄사가 터져 나왔다. 그리고 그 순간, 흩어져 있던 조각들이 맞춰졌다.

“아! 알겠다! 이 ClusterIP라는 건, 말 그대로 클러스터 내부에서만 쓸 수 있는 ‘내부 전용’ 주소였구나! 마치 우리 아파트 단지 안에서만 통하는 내선 번호 같은 거네. 외부에서는 이 번호로 직접 전화를 걸 수 없는 거고.”

“정확해.”

솔라는 이제 ClusterIP의 역할을 명확히 이해했다. 이것은 외부 사용자에게 서비스를 노출하기 위한 주소가 아니었다. 클러스터 안에서 여러 서비스(Pod 그룹)들이 서로를 안정적으로 찾아 통신하기 위해 존재하는, 내부 교통망과 같은 것이었다. 예를 들어, 웹 애플리케이션의 프론트엔드 Pod가 백엔드 API 서비스 Pod를 호출할 때, 바로 이 변하지 않는 ClusterIP를 사용하게 될 터였다.

실험을 마친 솔라는 exit 명령으로 debug-pod에서 빠져나왔다. --rm 옵션 덕분에 Pod는 흔적도 없이 사라졌다.

내부 통신의 비밀은 풀렸다. 하지만 동시에 새로운, 더 중요한 질문이 고개를 들었다.

“언니, 그럼 이제 알겠어. ClusterIP는 서비스들끼리 서로를 부를 때 쓰는 내부용 이름표구나. 그런데 진짜 문제, 그러니까 우리 웹서버를 인터넷을 쓰는 외부 사용자들에게 보여주려면 어떻게 해야 해? 클러스터 밖에서 안으로 들어갈 수 있는 진짜 ‘대문’이 필요하잖아.”

솔라의 시선은 다시 kubectl get service 결과의 TYPE 열로 향했다. ClusterIP는 그저 여러 문들 중 하나일 뿐이었다. 그렇다면 저 너머에는 어떤 종류의 문들이 더 기다리고 있을까. 그 질문이 다음 단계로 나아갈 길을 비추고 있었다.

4장: NodePort: 워커 노드 포트를 통한 외부 접속

솔라의 질문이 끝나자, 루나는 이전의 my-web-service.yaml 파일을 다시 화면에 띄웠다. 그리고는 키보드를 두드려 파일의 마지막 줄을 수정했다. 솔라가 의문을 제기했던 ‘진짜 대문’에 대한 대답이 마치 코드 한 줄에 담겨 있는 것 같았다.

화면에는 이전과 거의 같지만, 결정적인 한 줄이 다른 YAML 파일이 나타났다.

# my-web-service-nodeport.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-web-service
spec:
  selector:
    app: webserver
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 80
  type: NodePort # 이 부분이 바뀌었다.

이전 ClusterIP 타입의 서비스에서는 type 필드가 명시적으로 없었거나 ClusterIP로 되어 있었다. 하지만 이제 그 자리에는 NodePort라는 새로운 단어가 적혀 있었다. 이것이 클러스터의 보이지 않는 벽에 외부로 통하는 문을 내는 열쇠인 듯했다.

“NodePort… 노드 포트? 아!” 솔라의 눈이 반짝였다. “이건 클러스터를 구성하는 서버, 즉 워커 노드(Worker Node) 자체의 포트를 열어서 외부랑 연결해주는 방식인가 보다! 우리 웹서버 Pod가 지금 worker-node1에서 돌아가고 있으니까, 저 노드의 IP 주소로 접속할 수 있게 되는 거네?”

솔라의 추론은 직관적이고 합리적이었다. Pod가 특정 노드 위에서 실행되니, 그 노드에 구멍을 뚫어 트래픽을 전달하는 것이 가장 간단한 방법처럼 보였다. 그녀는 단순히 포트 하나가 열리는 장면을 상상했다.

루나는 솔라의 추측에 긍정도 부정도 하지 않은 채, 터미널을 열었다. “일단 기존 서비스를 지우고, 저 파일로 새로 만들어 보자. 그리고 어떤 변화가 생기는지 직접 확인하는 게 제일 확실하겠지.”

솔라는 먼저 기존의 ClusterIP 타입 서비스를 삭제했다.

kubectl delete service my-web-service

그리고 루나가 수정한 NodePort 타입의 YAML 파일을 적용했다.

kubectl apply -f my-web-service-nodeport.yaml

service/my-web-service configured

서비스가 성공적으로 변경되었다. 솔라는 즉시 서비스의 상태를 확인했다.

kubectl get service my-web-service -o wide

터미널에 출력된 결과는 이전과 사뭇 달랐다.

NAMETYPECLUSTER-IPEXTERNAL-IPPORT(S)AGESELECTOR
my-web-serviceNodePort10.103.142.1528080:31567/TCP5sapp=webserver

“오, TYPENodePort로 바뀌었고… 어? PORT(S) 필드가 이상해. 8080:31567/TCP? 이건 무슨 뜻이지?”

“앞의 8080은 이전과 같은 서비스의 내부 포트(ClusterIP 포트)야. 클러스터 안에서는 여전히 10.103.142.152:8080으로 접속할 수 있지. 중요한 건 뒤에 붙은 31567이야. 저게 바로 외부에서 접속할 때 사용하게 될 ‘노드 포트(NodePort)‘야. 쿠버네티스가 충돌하지 않는 범위에서 자동으로 할당해 준 포트지.”

이제 실험 준비는 끝났다. 솔라는 먼저 자신의 가설을 증명하기 위해, 클러스터의 노드 목록과 IP 주소를 확인했다.

kubectl get nodes -o wide
NAMESTATUSROLESAGEVERSIONINTERNAL-IPEXTERNAL-IP
worker-node1Ready26hv1.28.2192.168.10.111.2.3.4
worker-node2Ready26hv1.28.2192.168.10.125.6.7.8

“좋아. 웹서버 Pod는 지금 worker-node1에 있으니까, 저 노드의 외부 IP 1.2.3.4랑 방금 확인한 노드 포트 31567을 조합하면 되겠네.”

솔라는 자신의 노트북 터미널(클러스터 외부)에서 확신에 차 명령어를 입력했다.

curl 1.2.3.4:31567

결과는 성공이었다. 환영 메시지를 담은 HTML 코드가 화면에 즉시 출력되었다.

“됐다! 역시! Pod가 있는 노드에 포트가 열린 거였어.” 솔라는 만족스러운 미소를 지었다. 그녀의 가설이 맞아떨어지는 순간이었다.

그때, 루나가 조용히 화면을 가리키며 물었다. “그럼, Pod가 없는 worker-node2의 IP로 접속하면 어떻게 될까?”

worker-node2? 거기엔 웹서버 Pod가 없잖아. 당연히 접속이 안 되겠지. 연결 실패가 뜰 거야.” 솔라는 단호하게 대답했다.

“한번 해볼래?”

루나의 말에 솔라는 약간 의아했지만, 자신의 예측을 확인시켜주기 위해 키보드를 두드렸다. worker-node2의 외부 IP는 5.6.7.8이었다.

curl 5.6.7.8:31567

솔라는 Connection timed out 메시지를 예상하며 엔터 키를 눌렀다. 그러나, 그녀의 예상과 달리 화면에는 잠시의 지체도 없이 똑같은 HTML 코드가 출력되었다.

“어…?”

솔라의 눈이 동그래졌다. worker-node2에도 접속이 되었다. Pod가 있지도 않은 노드인데. 어떻게 된 일일까? 혼란스러운 표정으로 잠시 생각에 잠겼던 솔라의 얼굴에 이내 놀라움이 번졌다.

“말도 안 돼… 이것 봐, 언니! Pod가 없는 노드로 접속했는데도 똑같이 웹서버에 연결됐어! 그럼 NodePort라는 건… 특정 노드 하나에만 문을 여는 게 아니었구나!”

이제야 솔라는 NodePort의 진짜 모습을 마주했다. 그것은 Pod가 어디에 있든 상관없이, 클러스터의 모든 워커 노드에 동일한 포트 번호(31567)의 문을 활짝 열어두는 방식이었다. 사용자가 어느 노드의 문을 두드리든, 쿠버네티스는 내부의 똑똑한 네트워크(kube-proxy)를 통해 그 요청을 app: webserver 라벨을 가진 Pod에게 정확히 전달해 주었던 것이다.

“대박… 그러니까 나는 Pod의 위치를 전혀 신경 쓸 필요가 없었던 거네. 그냥 클러스터에 속한 아무 노드의 IP 주소 하나만 알면, 이 NodePort를 통해 서비스에 접속할 수 있었던 거야.”

솔라는 자신이 세웠던 ‘Pod가 있는 노드에만 포트가 열린다’는 가설이 얼마나 좁은 생각이었는지 깨달았다. NodePort는 훨씬 더 유연하고 강력한 외부 접속의 문이었다.

하지만 그 감탄도 잠시, 솔라의 머릿속에는 새로운 현실적인 의문이 떠올랐다.

“근데 언니, 이것도 완벽하진 않은 것 같아. 일단 내가 노드 IP 주소를 직접 알고 있어야 하고, 만약 worker-node1이 고장 나면 접속이 안 되니까 다른 노드 IP로 바꿔서 접속해야 하잖아. 그리고 사용자한테 내 웹사이트 주소는 1.2.3.4:31567 이야 라고 알려주기도 좀 이상하고… 우리가 보통 쓰는 www.example.com 같은 주소처럼, 깔끔한 외부 IP 주소 하나만 딱 받아서 연결할 수는 없을까?”

솔라는 더 편리하고, 더 안정적이며, 더 전문적인 ‘대문’을 원하고 있었다. 개별 노드의 IP가 아닌, 전체 서비스를 대표하는 단 하나의 외부 IP. 그 질문이 바로 다음 Service 타입인 LoadBalancer의 필요성을 가리키고 있었다.

5장: LoadBalancer: 클라우드 통합 외부 접속 및 타입 비교

솔라의 손가락은 키보드에서 떨어져 있었다. NodePort라는 문을 발견한 기쁨도 잠시, 그 문이 가진 한계가 머릿속을 복잡하게 만들었다. 여러 개의 노드 IP, 제각각 고장 날 가능성, 그리고 사용자에게 알려주기엔 너무 복잡한 주소와 포트 번호. 마치 여러 개의 임시 출입구를 두고 어디로 들어와야 할지 매번 고민해야 하는 상황 같았다. 더 전문적이고, 더 안정적인 단 하나의 ‘정문’은 없는 걸까?

그때, 루나의 손가락이 키보드 위에서 움직였다. 화면에 떠 있던 my-web-service-nodeport.yaml 파일의 내용이 바뀌고 있었다. 솔라의 질문에 대한 대답이 다시 한번 코드로 쓰여지는 순간이었다. 루나는 type: NodePort 라고 적힌 부분을 지우고 새로운 단어를 입력했다.

# my-web-service-lb.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-web-service
spec:
  selector:
    app: webserver
  ports:
    - protocol: TCP
      port: 80 # 이제 80번 포트를 직접 사용할 수 있다.
      targetPort: 80
  type: LoadBalancer # 최종 단계, LoadBalancer

“LoadBalancer… 로드밸런서?” 솔라가 화면의 단어를 소리 내어 읽었다. 이름만 들어서는 트래픽을 분산해주는, 뭔가 더 대단한 장치 같았다. “이건 NodePort랑 비슷한데, 여러 노드 IP 중에서 알아서 좋은 거 하나 골라서 고정된 IP를 주는 그런 건가?” 그녀는 LoadBalancer 역시 NodePort의 연장선상에 있는, 약간 더 똑똑해진 버전일 거라고 추측했다.

루나는 이번에도 바로 대답하는 대신, 터미널을 향해 고갯짓을 했다. 행동으로 직접 확인해 볼 시간이었다. 솔라는 먼저 이전의 NodePort 타입 서비스를 삭제하고, 새로 작성된 LoadBalancer 타입의 서비스를 클러스터에 적용했다.

kubectl delete service my-web-service
kubectl apply -f my-web-service-lb.yaml

service "my-web-service" deleted service/my-web-service created

그리고 곧바로 서비스의 상태를 확인했다. 어떤 마법 같은 일이 벌어질지 기대하며.

kubectl get service my-web-service

터미널 화면에 나타난 결과는 솔라의 예상을 조금 벗어나 있었다.

NAMETYPECLUSTER-IPEXTERNAL-IPPORT(S)AGE
my-web-serviceLoadBalancer10.103.142.152<pending>80/TCP3s

“어? EXTERNAL-IP가… <pending>? 대기 중?”

분명 무언가 일어나고 있었지만, 아직 끝나지 않은 상태였다. 이전과 달리 IP 주소가 바로 나타나지 않았다. NodePort처럼 단순히 노드의 포트를 여는 것과는 다른, 더 복잡한 과정이 진행되고 있음을 암시했다.

“조금만 기다려 봐. 지금 쿠버네티스가 클러스터 바깥 세상에 전화를 걸고 있는 중이야.” 루나가 조용히 말했다.

“전화를 건다고?”

“응. 쿠버네티스가 실행되고 있는 클라우드 환경, 예를 들면 AWS나 GCP, Azure 같은 곳에 ‘우리 서비스를 위한 진짜 외부용 IP 주소와 로드밸런서를 하나 만들어 주세요’라고 요청하는 거지.”

그 말을 듣는 순간, 솔라는 자신이 생각했던 것보다 훨씬 더 큰 그림이 그려지고 있음을 직감했다. LoadBalancer 타입은 쿠버네티스 내부의 기능이 아니라, 외부의 거대한 클라우드 인프라와 연동되는 장치였던 것이다.

솔라는 1분 정도 기다린 후, 다시 한번 같은 명령어를 입력했다.

kubectl get service my-web-service

이번에는 화면이 달랐다.

NAMETYPECLUSTER-IPEXTERNAL-IPPORT(S)AGE
my-web-serviceLoadBalancer10.103.142.15234.64.123.4580/TCP1m15s

<pending> 이라는 글자가 사라지고, 그 자리에 반짝이는 공인 IP 주소 34.64.123.45가 나타나 있었다. 이것은 노드의 IP가 아니었다. 클라우드 제공업체가 이 서비스를 위해 할당해 준, 전 세계 어디에서나 접속할 수 있는 유일무이한 ‘정문’의 주소였다.

솔라는 경탄을 금치 못했다. “와… 이건 쿠버네티스가 아니라 클라우드가 직접 만들어준 IP 주소구나!”

이제 마지막 확인만이 남았다. 솔라는 자신의 노트북 터미널에서, 새로 얻은 이 완벽한 주소로 접속을 시도했다. NodePort 때처럼 복잡한 포트 번호를 붙일 필요도 없었다. 서비스 YAML에 정의한 대로, 표준 HTTP 포트인 80번을 사용하면 되었다.

curl 34.64.123.45

엔터 키를 누르자마자, 기다렸다는 듯 웹서버의 환영 메시지가 화면에 출력되었다. 노드가 몇 개이든, Pod가 어느 노드에 있든, 심지어 어떤 노드가 고장 나더라도, 저 34.64.123.45라는 단 하나의 주소만 살아있으면 서비스는 계속 유지될 터였다. 클라우드 로드밸런서가 알아서 살아있는 노드로 트래픽을 보내줄 것이기 때문이었다.

솔라는 의자에 깊숙이 등을 기댔다. 마침내 모든 조각이 맞춰진 느낌이었다. 불안정한 Pod IP에서 시작해, 내부 통신용 ClusterIP, 테스트 및 제한적 외부 노출용 NodePort, 그리고 완벽한 외부 공개용 LoadBalancer까지. 왜 이렇게 여러 종류의 서비스가 필요했는지 이제 명확히 이해되었다. 각기 다른 목적과 범위를 가진, 서로 다른 종류의 ‘문’이었던 것이다.

“이제 알겠어, 언니. 어떤 문을 써야 할지는 우리가 뭘 하려는지에 따라 완전히 달라지는 거였어.”

솔라는 책상 위의 빈 메모지를 가져와 펜을 들었다. 그리고 자신만의 언어로 세 가지 시나리오를 적어 내려가기 시작했다. 마치 방금 배운 지식을 스스로에게 테스트하듯.


상황별 서비스 타입 선택 가이드

  1. 상황: 클러스터 내부의 ‘결제 서비스’가 ‘사용자 정보 서비스’를 호출해야 할 때. 외부에서는 절대 접근하면 안 됨.

    • 선택: ClusterIP
    • 이유: 클러스터 안에서만 통하는 고정된 내부 주소가 필요하니까. 가장 안전하고 기본적인 통신 방법.
  2. 상황: 개발팀에서 새로 만든 웹 애플리케이션을 정식 배포 전에 내부적으로 테스트하고 싶을 때. 관리자 몇 명만 접속해서 기능 확인.

    • 선택: NodePort
    • 이유: 외부 IP를 낭비하지 않고, 노드 IP와 특정 포트만 아는 사람들만 빠르게 접속해볼 수 있으니까. 임시 외부 접속 통로로 적합.
  3. 상황: 수백만 명의 사용자가 접속하는 우리의 메인 쇼핑몰 웹사이트. 항상 안정적으로 접속되어야 하고, 주소는 www.myshop.com 처럼 깔끔해야 함.

    • 선택: LoadBalancer
    • 이유: 클라우드가 제공하는 안정적인 외부 IP와 로드밸런싱 기능이 필수. 고가용성과 전문적인 서비스 제공에 적합.

메모를 마친 솔라는 만족스러운 표정으로 루나를 바라보았다. 더 이상 ‘Pod IP로도 되는데 왜?’라는 질문은 그녀의 머릿속에 없었다. 대신, 어떤 상황에 어떤 도구를 사용해야 가장 효과적인지를 판단하는 명확한 기준이 자리 잡았다. 불안정한 Pod의 세계 위에 안정적인 접속의 다리를 놓는 방법, 그 청사진이 마침내 완성된 것이다.