
지난 글에서 쿠버네티스의 핵심 리소스 5대 분류를 정리했다.
첫 번째로 다룰 주인공은 쿠버네티스 네트워킹의 시작이자 끝인 Service다.
나중에 다룰 Ingress나 차세대 Gateway API도 결국 뒤쪽에서 이 Service와 연결되어야만 동작한다. 즉, Service를 완벽히 이해하면 쿠버네티스 네트워크의 절반은 끝난다.
쿠버네티스에서 파드(Pod)는 영원히 사는 존재가 아니라 언제든 죽고 새로 태어나는 소모품이다.
10.244.x.x)가 매번 완전히 바뀐다.만약 프론트엔드 파드가 백엔드 파드의 IP(10.244.1.15)를 직접 적어서 통신하고 있었다면, 백엔드 파드가 재시작되는 순간 IP가 바뀌어 통신이 즉시 끊어지고 장애가 발생한다.
이 문제를 해결하기 위해 파드들 앞단에 절대 변하지 않는 고정 IP와 고정 도메인 이름을 가진 '단일 창구'를 세우는데, 이것이 바로 Service다.
처음 Service YAML을 작성할 때 가장 헷갈리는 부분이 이것이다.
"YAML 파일에 목적지(
selector)는 적는 것 같은데, 누가(출발지) 보내는지는 어디에 적는 거지? 그리고 이 창구가 어떻게 실제 파드들을 찾아가는 거지?"
프론트엔드 파드가 백엔드 파드로 통신을 보낼 때, 쿠버네티스 내부에서는 아래 4단계의 톱니바퀴(CoreDNS -> Service -> Endpoints -> kube-proxy)가 맞물려 돌아간다.
[1. 출발지: Frontend 파드]
│
│ ("http://backend-svc:80" 주소가 어디야? -> CoreDNS가 10.96.0.10 이라고 알려줌)
▼
[2. 고정 창구: backend-svc (Service / 가상 IP: 10.96.0.10:80)]
│
│ (kube-proxy가 가로채서 Endpoints 명단에 적힌 실제 파드 IP로 목적지를 바꿔치기)
▼
[3. 목적지: Backend 파드들 (10.244.1.5:8080, 10.244.2.8:8080 ...)]
실제 YAML 코드 3개가 어떻게 연결되는지 직접 확인해 보자.
먼저 요청을 처리할 백엔드 파드 몸체에 app: backend-api라는 이름표(Label)를 붙여 두고, 컨테이너 내부의 8080 포트를 열어 둔다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend-deploy
namespace: prod
spec:
replicas: 3
selector:
matchLabels:
app: backend-api
template:
metadata:
labels:
app: backend-api # [핵심 1] 파드 몸체에 붙여둔 이름표
spec:
containers:
- name: backend-container
image: my-backend:1.0
ports:
- containerPort: 8080 # [핵심 2] 컨테이너가 실제로 열고 있는 포트
이제 Service를 생성한다. Service는 selector를 통해 클러스터 전체를 스캔하여 app: backend-api 이름표가 붙은 파드들의 현재 IP 목록을 실시간으로 수집한다.
apiVersion: v1
kind: Service
metadata:
name: backend-svc # [핵심 3] 클러스터 내부 전화번호부(CoreDNS)에 등록될 이름
namespace: prod
spec:
type: ClusterIP # 기본값 (클러스터 내부 전용 창구)
selector:
app: backend-api # [어디로 보낼까?] 이 이름표가 붙은 파드들에게만 보낸다
ports:
- name: http
protocol: TCP
port: 80 # [서비스 포트] 프론트엔드가 호출할 서비스의 입구 포트
targetPort: 8080 # [타겟 포트] 뒤쪽 백엔드 파드의 몇 번 포트로 꽂아줄지 지정
그렇다면 "어디서 들어오는지(출발지)"는 어떻게 연결할까?
쿠버네티스에는 CoreDNS라는 내장 DNS 서버가 있다. backend-svc라는 서비스를 만드는 순간, 클러스터 내부에는 자동으로 http://backend-svc:80 이라는 도메인 주소가 등록된다.
따라서 프론트엔드 파드는 백엔드 파드의 IP를 알 필요 없이, 그냥 http://backend-svc:80으로 요청을 던지기만 하면 된다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-deploy
namespace: prod
spec:
replicas: 2
selector:
matchLabels:
app: frontend-web
template:
metadata:
labels:
app: frontend-web
spec:
containers:
- name: frontend
image: my-frontend:1.0
env:
# [어디서 보낼까?] 출발지 파드의 환경변수에 서비스 이름과 서비스 포트(80)를 적어준다
- name: BACKEND_API_URL
value: "http://backend-svc:80"
단순히 "Service가 파드로 보내준다"를 넘어, 리눅스 시스템 레벨에서 실제로 어떻게 패킷이 전달되는지 알면 장애 트러블슈팅이 훨씬 쉬워진다.
backend-svc를 만들면 쿠버네티스는 똑같은 이름(backend-svc)의 Endpoints라는 객체를 자동으로 함께 만든다.app: backend-api 파드들의 실제 IP와 포트(10.244.1.5:8080, 10.244.2.8:8080)가 적혀 있다. 파드가 죽으면 즉시 이 명부에서 빠지고, 새 파드가 뜨면 즉시 추가된다.10.96.0.10)는 실제 랜카드에 할당된 진짜 IP가 아니라 허상(가상 IP)이다.kube-proxy 데몬이 리눅스 커널의 네트워크 전달 규칙을 조작해 둔다.10.96.0.10:80으로 패킷을 던지면, 커널이 그 패킷을 중간에서 낚아채어 Endpoints 명부에 적힌 실제 백엔드 파드 IP(10.244.1.5:8080)로 목적지 주소를 바꿔치기(DNAT)하여 전달한다.Service는 이 창구를 '어디까지 열어줄 것인가'에 따라 크게 3가지 타입으로 나뉜다.
| 서비스 타입 | 노출 범위 | 포트 구성 | 주요 용도 |
|---|---|---|---|
| ClusterIP | 클러스터 내부 전용 | port -> targetPort | 백엔드 API, 내부 DB, Redis 등 외부 노출이 불필요한 모든 내부 통신 |
| NodePort | 워커 노드(EC2) IP로 외부 노출 | nodePort -> port -> targetPort | 임시 테스트, 온프레미스 환경, 혹은 외부 로드밸런서와의 연결 통로 |
| LoadBalancer | 클라우드 외부 로드밸런서 생성 | LB Port -> nodePort -> port -> targetPort | 외부 인터넷 사용자에게 단일 서비스를 직접 노출할 때 (AWS NLB 연동) |
NodePort YAML을 보면 포트가 3개나 나와서 입문자들을 당황하게 만든다.
apiVersion: v1
kind: Service
metadata:
name: web-nodeport-svc
spec:
type: NodePort
selector:
app: web-app
ports:
- protocol: TCP
nodePort: 30080 # 1. [노드 입구] 워커 노드(EC2) 외벽에 뚫어놓은 포트 (30000~32767 사이만 가능)
port: 80 # 2. [서비스 입구] 클러스터 내부에서 이 서비스를 부를 때 쓰는 포트
targetPort: 8080 # 3. [파드 입구] 실제 컨테이너 애플리케이션이 듣고 있는 포트
http://<워커노드_공인IP>:30080으로 접속하면 -> 서비스의 80 포트를 거쳐 -> 파드의 8080 포트로 들어간다.30080 포트로 접속하든, 해당 노드에 파드가 없어도 kube-proxy가 파드가 있는 다른 노드로 알아서 패킷을 토스해 준다.type: LoadBalancer를 선언하면 외부 사용자가 접속할 수 있는 진짜 로드밸런서가 생긴다. 그런데 여기서 반드시 알아야 할 원리가 있다.
아니다. 쿠버네티스 자체는 AWS가 뭔지 전혀 모른다.
우리가 YAML에 type: LoadBalancer라고 적는 것은 쿠버네티스 게시판에 "나 외부 로드밸런서 하나 필요함!"이라는 '종이 주문서'를 붙여놓는 것뿐이다.
이 주문서를 실제로 처리하려면, 게시판을 24시간 감시하다가 주문서가 올라왔을 때 AWS에 API로 요청을 보내 실제 로드밸런서(NLB) 장비를 생성해 주는 '심부름꾼 프로그램'이 클러스터 안에 떠 있어야 한다. 이를 컨트롤러(Controller)라고 부른다.
(참고로 Service 리소스는 도메인이나 URL 경로가 아닌 TCP/UDP 포트(L4)만 다루는 스펙이기 때문에, AWS에서도 L7인 ALB가 아니라 L4 로드밸런서인 NLB가 생성된다.)
apiVersion: v1
kind: Service
metadata:
name: payment-nlb-svc
annotations:
# 심부름꾼(AWS Load Balancer Controller)에게 NLB를 만들라고 지시하는 메모
service.beta.kubernetes.io/aws-load-balancer-type: "external"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip"
service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
spec:
type: LoadBalancer
selector:
app: payment-engine
ports:
- protocol: TCP
port: 443
targetPort: 8443
<pending> 현상과 MetalLB)내 방의 리눅스 서버나 회사 전산실(온프레미스 베어메탈)에 직접 구축한 쿠버네티스에서 type: LoadBalancer를 선언하면 어떻게 될까?
$ kubectl get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
payment-nlb-svc LoadBalancer 10.96.45.12 <pending> 443:31234/TCP
주문서를 받아 장비를 만들어 줄 클라우드(AWS)가 없기 때문에 EXTERNAL-IP가 영원히 <pending>(무한 대기) 상태에서 멈춰버린다.
MetalLB에게 사내 공유기/스위치의 남는 IP 대역(예: 192.168.10.200~250)을 설정해 주면, MetalLB가 주문서를 보고 그중 IP 하나를 즉시 할당해 외부 접속이 가능하도록 만들어 준다.이번 글에서 다룬 Service의 핵심을 요약하면 다음과 같다.
1. 파드(Pod)는 죽었다 살아날 때마다 IP가 바뀌므로, 앞단에 고정된 창구인 Service가 반드시 필요하다.
2. 출발지 파드는 CoreDNS에 등록된 서비스 이름을 호출하고, 서비스는 selector를 통해 목적지 파드의 labels(이름표)를 찾아 연결한다.
3. 실제 트래픽 전달은 서비스 뒤에 숨은 Endpoints(생존 파드 명단)와 커널 규칙을 조작하는 kube-proxy가 수행한다.
4. type: LoadBalancer는 컨트롤러(AWS Load Balancer Controller 또는 MetalLB)라는 심부름꾼을 통해 외부 L4 로드밸런서(AWS NLB 등)를 생성한다.
Service(type: LoadBalancer)만 있으면 외부 노출까지 다 되는데 왜 굳이 Ingress와 Gateway API를 또 배워야 할까?
만약 우리 회사의 마이크로서비스가 30개라면, type: LoadBalancer를 30개 만드는 순간 비싼 AWS NLB가 30개 생성되어 매달 수백만 원의 비용 폭탄을 맞기 때문이다. 또한 Service는 L4(IP/포트)만 볼 수 있어서 URL 경로(/orders, /pay)별로 트래픽을 나눌 수도 없다.
다음 글에서는 단 1개의 L7 로드밸런서(ALB)로 수십 개의 서비스를 도메인과 경로별로 우아하게 묶어주는 안내데스크, Ingress와 차세대 표준 Gateway API를 본격적으로 파헤쳐 본다.
이상.