[Infra] Kubernetes 기초 : 네트워크 - Service

고수가 되고 싶은 감자·2026년 9월 28일
post-thumbnail

지난 글에서 쿠버네티스의 핵심 리소스 5대 분류를 정리했다.
첫 번째로 다룰 주인공은 쿠버네티스 네트워킹의 시작이자 끝인 Service다.
나중에 다룰 Ingress나 차세대 Gateway API도 결국 뒤쪽에서 이 Service와 연결되어야만 동작한다. 즉, Service를 완벽히 이해하면 쿠버네티스 네트워크의 절반은 끝난다.


1. 왜 파드(Pod)끼리 직접 통신하지 않고 Service를 거칠까?

쿠버네티스에서 파드(Pod)는 영원히 사는 존재가 아니라 언제든 죽고 새로 태어나는 소모품이다.

  • 트래픽이 몰려 오토스케일링(HPA)이 동작하면 파드가 3개에서 10개로 늘어난다.
  • 새 버전을 배포(Rolling Update)하면 기존 파드가 전부 삭제되고 새로운 파드가 뜬다.
  • 결정적 문제: 파드가 새로 생성될 때마다 파드의 IP 주소(10.244.x.x)가 매번 완전히 바뀐다.

만약 프론트엔드 파드가 백엔드 파드의 IP(10.244.1.15)를 직접 적어서 통신하고 있었다면, 백엔드 파드가 재시작되는 순간 IP가 바뀌어 통신이 즉시 끊어지고 장애가 발생한다.

이 문제를 해결하기 위해 파드들 앞단에 절대 변하지 않는 고정 IP와 고정 도메인 이름을 가진 '단일 창구'를 세우는데, 이것이 바로 Service다.


2. 핵심 원리 : 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개가 어떻게 연결되는지 직접 확인해 보자.

Step 1. 목적지 파드 (Backend Deployment) : "이름표(labels)를 붙인다"

먼저 요청을 처리할 백엔드 파드 몸체에 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] 컨테이너가 실제로 열고 있는 포트

Step 2. 중간 창구 (Service) : "자석(selector)으로 이름표를 모은다"

이제 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            # [타겟 포트] 뒤쪽 백엔드 파드의 몇 번 포트로 꽂아줄지 지정

Step 3. 출발지 (Frontend 파드) : "서비스의 도메인 이름을 부른다"

그렇다면 "어디서 들어오는지(출발지)"는 어떻게 연결할까?
쿠버네티스에는 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"

3. 조금 더 깊게 : Endpoints와 kube-proxy는 밑바닥에서 무슨 일을 할까?

단순히 "Service가 파드로 보내준다"를 넘어, 리눅스 시스템 레벨에서 실제로 어떻게 패킷이 전달되는지 알면 장애 트러블슈팅이 훨씬 쉬워진다.

  1. Endpoints (실시간 생존자 명부):
    • backend-svc를 만들면 쿠버네티스는 똑같은 이름(backend-svc)의 Endpoints라는 객체를 자동으로 함께 만든다.
    • 여기에 현재 살아있는 app: backend-api 파드들의 실제 IP와 포트(10.244.1.5:8080, 10.244.2.8:8080)가 적혀 있다. 파드가 죽으면 즉시 이 명부에서 빠지고, 새 파드가 뜨면 즉시 추가된다.
  2. kube-proxy (패킷 주소를 바꿔치기하는 일꾼):
    • 사실 Service가 받은 IP(10.96.0.10)는 실제 랜카드에 할당된 진짜 IP가 아니라 허상(가상 IP)이다.
    • 모든 워커 노드에 떠 있는 kube-proxy 데몬이 리눅스 커널의 네트워크 전달 규칙을 조작해 둔다.
    • 프론트엔드 파드가 10.96.0.10:80으로 패킷을 던지면, 커널이 그 패킷을 중간에서 낚아채어 Endpoints 명부에 적힌 실제 백엔드 파드 IP(10.244.1.5:8080)로 목적지 주소를 바꿔치기(DNAT)하여 전달한다.

4. Service의 3가지 핵심 타입과 포트 완벽 정리

Service는 이 창구를 '어디까지 열어줄 것인가'에 따라 크게 3가지 타입으로 나뉜다.

서비스 타입노출 범위포트 구성주요 용도
ClusterIP클러스터 내부 전용port -> targetPort백엔드 API, 내부 DB, Redis 등 외부 노출이 불필요한 모든 내부 통신
NodePort워커 노드(EC2) IP로 외부 노출nodePort -> port -> targetPort임시 테스트, 온프레미스 환경, 혹은 외부 로드밸런서와의 연결 통로
LoadBalancer클라우드 외부 로드밸런서 생성LB Port -> nodePort -> port -> targetPort외부 인터넷 사용자에게 단일 서비스를 직접 노출할 때 (AWS NLB 연동)

1) NodePort와 3가지 포트의 차이 (nodePort vs port vs targetPort)

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 포트로 들어간다.
  • 주의: 어떤 워커 노드(EC2)의 30080 포트로 접속하든, 해당 노드에 파드가 없어도 kube-proxy가 파드가 있는 다른 노드로 알아서 패킷을 토스해 준다.

2) LoadBalancer와 컨트롤러(Controller)의 진짜 정체

type: LoadBalancer를 선언하면 외부 사용자가 접속할 수 있는 진짜 로드밸런서가 생긴다. 그런데 여기서 반드시 알아야 할 원리가 있다.

쿠버네티스가 직접 AWS 로드밸런서를 만들까? (컨트롤러의 역할)

아니다. 쿠버네티스 자체는 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

만약 AWS 클라우드 환경이 아니라면? (<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):
    이때는 클러스터에 MetalLB라는 오픈소스 컨트롤러(심부름꾼)를 직접 설치한다. MetalLB에게 사내 공유기/스위치의 남는 IP 대역(예: 192.168.10.200~250)을 설정해 주면, MetalLB가 주문서를 보고 그중 IP 하나를 즉시 할당해 외부 접속이 가능하도록 만들어 준다.

5. 마치며 (그리고 다음 이야기: 왜 Service만으로는 부족할까?)

이번 글에서 다룬 Service의 핵심을 요약하면 다음과 같다.
1. 파드(Pod)는 죽었다 살아날 때마다 IP가 바뀌므로, 앞단에 고정된 창구인 Service가 반드시 필요하다.
2. 출발지 파드는 CoreDNS에 등록된 서비스 이름을 호출하고, 서비스는 selector를 통해 목적지 파드의 labels(이름표)를 찾아 연결한다.
3. 실제 트래픽 전달은 서비스 뒤에 숨은 Endpoints(생존 파드 명단)와 커널 규칙을 조작하는 kube-proxy가 수행한다.
4. type: LoadBalancer는 컨트롤러(AWS Load Balancer Controller 또는 MetalLB)라는 심부름꾼을 통해 외부 L4 로드밸런서(AWS NLB 등)를 생성한다.

다음 글 예고 : 왜 Ingress와 Gateway API가 등장했는가?

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를 본격적으로 파헤쳐 본다.

이상.

profile
DevOps 엔지니어 지망생

0개의 댓글