
지난 포스팅에서는 쿠버네티스 네트워크의 기본인 ClusterIP와 파드가 죽고 살아나며 IP가 바뀌어도 변함없이 목적지를 찾아가게 해주는 Service Discovery(내부 DNS) 에 대해 알아보았습니다.
ClusterIP와 Service Discovery는 "클러스터 내부에 있는 파드들끼리 어떻게 통신할 것인가"에 집중한 기술인데, 여기서 생기는 궁금증은 "우리가 만든 웹 서비스를, 클러스터 밖에 있는 외부 사용자(인터넷)가 어떻게 접속하게 만들 것인가?" 입니다. ClusterIP는 말 그대로 클러스터 '내부 전용'이므로 외부에서는 절대 접근할 수가 없습니다.
이번 포스팅에서는 외부 트래픽을 안전하게 내부 파드로 연결해 주는 NodePort, LoadBalancer, 그리고 온프레미스 환경을 위한 MetalLB 로드밸런서에 대해 완벽하게 파헤쳐 보겠습니다. 🚀
클러스터 외부에서 내부로 접근하기 위한 가장 원초적인 방법은 워커 노드(실제 컴퓨터)의 IP를 직접 이용하는 것입니다.
크게 두 가지 방식이 있습니다.
ExternalIP는 특정 워커 노드의 IP와 포트를 콕 집어서, 해당 노드로 들어온 트래픽을 서비스에 연결된 파드들로 쏴주는 서비스입니다.
# sample-externalip.yaml
apiVersion: v1
kind: Service
metadata:
name: sample-externalip
spec:
type: ClusterIP
externalIPs:
- 10.0.2.102 # WORKER1의 IP를 실제 외부 IP로 지정
- 10.0.2.103 # WORKER2의 IP를 실제 외부 IP로 지정
ports:
- name: 'http-port'
protocol: 'TCP'
port: 8080
targetPort: 80
selector:
app: sample-app
이렇게 설정하면 (10.0.2.102:8080 등)을 통해 클러스터 외부에서도 서비스에 접근할 수 있게 됩니다.
먼저 서비스와 디플로이먼트를 배포한 뒤, 생성된 파드들이 각각 어느 노드에 떠 있는지 상세 정보(-o wide)를 확인해 보겠습니다.
$ kubectl apply -f sample-deployment.yaml
$ kubectl apply -f sample-externalip.yaml
$ kubectl get pods -o wide

NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
sample-deployment-5858ffc5f5-f6qvn 1/1 Running 0 4m36s 10.244.2.143 worker1 <none> <none>
sample-deployment-5858ffc5f5-h8zpb 1/1 Running 0 9s 10.244.1.200 worker2 <none> <none>
sample-deployment-5858ffc5f5-t9wl8 1/1 Running 0 4m36s 10.244.1.199 worker2 <none> <none>
결과를 보면 파드들이 worker1과 worker2에 나뉘어 배포된 것을 확인할 수 있습니다.
이제 지정했던 외부 IP 중 하나인 worker1(10.0.2.102)으로 실제 웹 요청(curl)을 여러 번 보내보겠습니다.
$ curl http://10.0.2.102:8080 # 이거 여러번 보내기

seongyun@master:~/k8s$ curl http://10.0.2.102:8080
Host=10.0.2.102 Path=/ From=sample-deployment-5858ffc5f5-t9wl8 ClientIP=10.244.0.0 XFF=
seongyun@master:~/k8s$ curl http://10.0.2.102:8080
Host=10.0.2.102 Path=/ From=sample-deployment-5858ffc5f5-f6qvn ClientIP=10.244.0.0 XFF=
seongyun@master:~/k8s$ curl http://10.0.2.102:8080
Host=10.0.2.102 Path=/ From=sample-deployment-5858ffc5f5-f6qvn ClientIP=10.244.0.0 XFF=
seongyun@master:~/k8s$ curl http://10.0.2.102:8080
Host=10.0.2.102 Path=/ From=sample-deployment-5858ffc5f5-t9wl8 ClientIP=10.244.0.0 XFF=
seongyun@master:~/k8s$ curl http://10.0.2.102:8080
Host=10.0.2.102 Path=/ From=sample-deployment-5858ffc5f5-t9wl8 ClientIP=10.244.0.0 XFF=
출력 결과를 자세히 보면 아주 흥미로운 점을 발견할 수 있습니다. 분명히 workr1의 IP로만 요청을 보냈는데, 응답은 worker1에 있는 파드(f6qvn)뿐만 아니라 worker2에 있는 파드(t9wl8)에서도 오고 있습니다.
이것이 바로 쿠버네티스 내부의 kube-proxy 가 열심히 일하고 있다는 증거입니다. worker1로 들어온 트래픽을 kube-proxy가 가로채서, 연결된 전체 파드들에게 적절히 로드밸런싱을 해주는 것이죠.
하지만 ExternalIP는 치명적인 단점이 있습니다. 매니페스트에 수동으로 등록한 특정 IP(노드)에만 의존하기 때문에, 만약 해당 노드가 장애로 다운되면 외부에서 접근할 방법이 완전히 차단된다는 한계가 명확합니다.
ExternalIP가 특정 노드의 IP만 수동으로 지정했다면, NodePort는 클러스터 내의 모든 워커 노드에 동일한 포트(기본 30000~32767)를 뚫어서 외부 트래픽을 받습니다.
# sample-nodeport.yaml
apiVersion: v1
kind: Service
metadata:
name: sample-nodeport
spec:
type: NodePort
ports:
- name: "http-port"
protocol: "TCP"
port: 8080 # 클러스터 내부(ClusterIP) 포트
targetPort: 80 # 실제 컨테이너 포트
nodePort: 30080 # 외부 노출 포트 (모든 노드의 30080 포트가 열림!)
selector:
app: sample-app
NodePort를 생성하면 내부적으로 ClusterIP도 알아서 생성됩니다. 여기서 NodePort의 가장 핵심적인 특징이 등장합니다.
사용자는 클러스터 내의 어떤 워커 노드 IP로 접속하든 지정한 포트(30080)만 맞으면 서비스에 접근할 수 있습니다.
그렇다면 "만약 내가 접속한 워커 노드에 파드가 하나도 없다면 어떻게 될까요?"
정답은 아무 문제가 없다! 입니다. ExternalIP할 때도 말했다시피 문지기? 역할을 하는 kube-proxy가 트래픽을 낚아챈 뒤, '이 서비스의 파드들은 다른 워커 노드에 있다'는 것을 알아차리고 클러스터 내부망을 통해 알아서 트래픽을 안전하게 토스해줍니다.
🚨 주의: 실무에서 NodePort를 지양하는 이유
개념이 쉽고 직관적이라 테스트 환경에서는 자주 쓰지만, 실제 프로덕션(Production) 환경에서는 다음과 같은 치명적인 단점들 때문에 거의 사용하지 않습니다.
- 보안 취약점: 워커 노드(실제 물리/ 가상 서버)의 공인 IP와 포트가 외부에 그대로 발가벗겨진 채 노출되므로 해커들의 좋은 타겟이 될 수 있습니다.
- 낮은 가용성과 SPOF(단일 장애점): 외부 클라이언트가 특정 워커 노드 IP(예: 10.0.2.102)를 바라보고 통신 중인데, 이 노드가 고장나거나 IP가 바뀌면? 그대로 서비스는 먹통이 됩니다. 클라이언트 측에서 접속 IP를 일일이 바꿔줘야 하는 끔찍한 상황이 발생합니다.
💡쉽게 말해, "클라이언트에게 클러스터 내부의 실제 물리 서버(노드) 주소를 직접 알려주고 알아서 찾아오라고 하는 방식"이기 때문입니다.
서버의 진짜 주소가 외부 인터넷에 노출되어 방화벽 등 보안 관리가 어려워지고, 사용자가 접속을 시도하던 해당 서버 한 대만 다운되어도 클러스터 내의 다른 서버들이 멀쩡히 살아있는지 여부와 상관없이 서비스 접속이 완전히 끊어지게 됩니다.
위에서 언급한 NodePort의 한계를 해결하기 위해 등장한 것이 바로 LoadBalancer 타입입니다. 실무에서 외부 트래픽을 받을 때 사용하는 표준 방법 이기도 합니다.
이 방식은 워커 노드의 IP를 직접 노출하지 않고, 클러스터 외부에 든든한 로드밸런서를 하나 세워서 들어오는 트래픽을 안전하게 분배합니다.
# sample-lb.yaml
apiVersion: v1
kind: Service
metadata:
name: sample-lb
spec:
type: LoadBalancer
ports:
- name: "http-port"
protocol: "TCP"
port: 8080
targetPort: 80
nodePort: 30082 # 내부적으로 NodePort도 함께 생성되어 연결됨
selector:
app: sample-app
실제로 위의 파일을 만들어 배포를 해봅니다.
🤔 앗! EXTERNAL-IP가 <pending>에서 멈췄어요!
AWS나 GCP 같은 퍼블릭 클라우드 환경에서는 위 매니페스트를 배포하면 클라우드 제공자(CSP)가 자동으로 로드밸런서를 프로비저닝하고 공인 IP를 할당해줍니다.
하지만, 우리가 직접 구축한 온프레미스(로컬) K8s 환경에서 배포하면 어떻게 될까요?
$ kubectl get svc sample-lb
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
sample-lb LoadBalancer 10.106.12.34 <pending> 8080:30082/TCP 5m
위와 같이 EXTERNAL-IP가 <pending> 상태로 무한 대기하게 됩니다.
그 이유는 쿠버네티스 자체에는 로드밸런서를 생성하고 IP를 부여할 능력이 없기 때문입니다. 쿠버네티스는 외부 환경에 "IP 하나만 줘!" 요청만 할 뿐인데, 로컬 환경에는 이 요청을 받아서 IP를 던져줄 AWS 같은 클라우드 장비가 없으니 하염없이 기다리기만 하는 것이죠.
🦸♂️ 로컬 환경의 구원투수: MetalLB의 등장
이 <pending>의 늪에서 우리를 구원해 줄 오픈소스가 바로 MetalLB입니다. MetalLB는 로컬(베어메탈) 환경에서도 마치 클라우드 로드밸런서가 있는 것처럼 동작하게 만들어주는 도구입니다.
MetalLB에 관한 내용은 아래에서 더 자세히 알아보도록 합시다.
내부 인프라 완벽 숨김: 사용자는 로드밸런서의 External IP 하나만 알고 그곳으로만 접속하면 됩니다. 클러스터 내부 워커 노드들의 실제 IP 구조는 철저히 감춰지므로 보안이 크게 강화됩니다.
알아서 해주는 고가용성: 로드밸런서가 비정상(죽은) 노드로는 트래픽을 보내지 않도록 알아서 관리해 주므로 단일 장애점(SPOF) 문제가 해결되고 서비스 안정성이 수직 상승합니다.
고정 IP 할당: 실무에서는 DNS 연동을 위해 IP가 바뀌면 안 될 때가 있습니다. spec.loadBalancerIP: 10.0.2.200 처럼 지정해주면 고정 IP를 확보할 수 있습니다.
방화벽(접속 제어): 기본적으로 LoadBalancer는 전 세계(0.0.0.0/0)에 공개됩니다. 특정 회사 IP 대역만 허용하려면 spec.loadBalancerSourceRanges: ["10.0.2.15/24"] 처럼 발신 측 네트워크를 제한할 수 있습니다.
앞서 살펴봤듯, 온프레미스 쿠버네티스 환경에서는 LoadBalancer 타입의 아무리 기다려도 EXTERNAL-IP가 계속 <pending> 상태에 머물러 있기 때문입니다.
그 이유는 로컬/베어메탈 환경에는 AWS나 GCP처럼 "나 대신 로드밸런서를 만들어 줄 클라우드 제공자"가 없기 때문입니다.
이것을 해결해 주는 것이 바로 MetalLB (BareMetal LoadBalancer) 입니다! 온프레미스(자체 구축) 환경에서도 type: LoadBalancer를 사용할 수 있게 해줍니다.
MetalLB는 크게 L2 모드 (ARP를 이용한 IP 광고)와 BGP 모드를 지원합니다. 이번 실습에서는 가장 대중적이고 설정이 간편한 L2 모드로 구성해 보겠습니다.
1) MetalLB 구성 요소 설치
# 컨트롤러(Controller)와 스피커(Speaker) 파드가 배포됩니다.
$ kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.13.10/config/manifests/metallb-native.yaml
Controller: 서비스에 할당할 IP를 결정하는 지휘본부 역할을 합니다.
Speaker: 각 노드에서 DaemonSet으로 실행되며, 할당된 IP가 자신의 노드에 있다고 외부 네트워크에 광고 (ARP) 하는 역할을 수행합니다.
2) IP Address Pool 생성 (로드밸런서 IP 대역 할당)
로드밸런서가 서비스들에 나누어 줄 수 있는 IP 주소 Pool을 정의합니다.
# my-ippool.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: my-ippool
namespace: metallb-system
spec:
addresses:
- 10.0.2.150-10.0.2.250 # 내 외부 네트워크 대역에 맞는 가용 IP 범위 지정
🚨주의: IP 대역 설정 시 필수 확인!
MetalLB(L2 모드)가 나누어줄 IP는 워커 노드들이 속해 있는 동일한 로컬 네트워크 대역(서브넷) 이어야 외부에서 정상적으로 찾아 들어올 수 있습니다.
파일 작성 전에 반드시 kubectl get nodes -o wide 명령어를 입력하여 노드들의 실제 IP 대역(예: 10.0.2.x)을 먼저 확인하고, 남는 가용 IP 범위를 지정해 주어야 합니다.
3) L2 Advertisement 설정 (IP 광고 시작)
작성한 IP 풀을 외부로 알릴 수 있도록 광고 설정을 연결해 줍니다.
# my-l2-advertise.yaml
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: my-l2-advertise
namespace: metallb-system
spec:
ipAddressPools:
- my-ippool
이렇게 설정을 마치고 다시 LoadBalancer 서비스를 조회해 보면, 무한 대기 중이던 <pending>이 사라지고 우리가 지정한 범위 내의 IP(예: 10.0.2.150)가 할당된 것을 확인할 수 있습니다.

클러스터 내부의 자원을 외부로 노출할 때, 워커 노드의 실제 IP를 노출하는 NodePort는 몹시 위험하고 불안정합니다.
따라서 클라이언트와 쿠버네티스 노드 사이에 LoadBalancer를 세우는 것이 실무의 룰입니다. 클라우드 환경이 아니라면 MetalLB를 통해 그 역할을 똑같이 흉내 낼 수 있다는 점도 꼭 기억해야 합니다.
그런데 말입니다... 서비스를 실무에서 운영하다 보면 단순히 밖으로 열어주는 것을 넘어 '특정 사용자는 계속 같은 파드로 접속하게 해줘!', '불필요한 네트워크 지연을 줄여줘!', 혹은 '외부 클라우드 DB를 내부 서비스처럼 쓰게 해줘!' 와 같은 복잡한 요구사항들이 생겨납니다.
다음 포스팅에서는 Ingress로 넘어가기 전, 쿠버네티스 Service API 고급 기능 5가지(Headless, ExternalName 등) 에 대해 먼저 알아보겠습니다! 🔥"