[Kubernetes] 머신러닝 플랫폼 엔지니어링: K8s와 CI/CD - 3.3.4. Service와 클러스터 안 트래픽 경로

· 9 분 소요

MLOps › 인프라 기반·Kubernetes › 네트워킹·스터디

시리즈 · 머신러닝 플랫폼 엔지니어링: K8s와 CI/CD (7 / 12)·← 이전 편·다음 편 →

서종호(가시다)님의 Machine Learning Platform Engineering Study 1주차 학습 내용을 기반으로 합니다.


TL;DR

  • Service는 파드가 교체돼도 사용할 수 있는 주소와 포트를 제공한다. 셀렉터가 선택한 파드의 IP와 포트가 통신 대상이 된다
  • NodePort 30420을 kind의 호스트 포트 매핑과 연결해 Mac과 같은 LAN의 다른 PC에서 앱 응답을 받았다
  • ClusterIP 호출, 서비스명 호출, 파드의 DNS 설정과 노드의 iptables 규칙을 확인했다. 별도의 nslookup에는 NXDOMAIN도 기록됐다
  • kind의 기본 구성에는 LoadBalancer를 구현할 컨트롤러가 없어 외부 주소가 <pending>으로 남았다


Service 개념

책 3.3.4절에 대응하는 실습이다. 3.3.3편에서는 파드가 재생성되며 이름과 IP가 바뀌었다. 클라이언트가 이 변화를 매번 따라가지 않고 앱을 호출할 수 있도록 Service를 붙인다.

서비스(Service)는 파드 같은 백엔드 집합을 하나의 네트워크 접근 지점으로 노출하는 오브젝트다. 일반적인 Service에는 클러스터 IP와 DNS 이름이 부여된다. 셀렉터(selector)가 있는 Service라면 같은 네임스페이스에서 레이블이 맞는 파드가 백엔드가 된다. EndpointSlice는 이 백엔드들의 주소·포트·준비 상태를 담는 오브젝트다.

flowchart LR
    %% 서비스 주소를 통해 애플리케이션에 접근
    U[사용자] --> S1[프런트엔드 Service]
    S1 --> F[프런트엔드 파드]
    F --> S2[백엔드 Service]
    S2 --> B[백엔드 파드]
출처: 『머신러닝 플랫폼 엔지니어링』 그림 3.6. AI를 이용해 재구성했다

이번에는 ClusterIP, NodePort, LoadBalancer 세 유형을 적용한다. 실습 위치 ~/ml-study/chapters/ch03-k8s는 실습 저장소 경로를 줄인 표기다. kubectl은 쿠버네티스 API를 호출하는 CLI이며, apply -f는 파일의 선언을 반영한다. 앱은 v1 이미지의 Deployment를 다시 생성해 준비했다.

# hello-joker 파드 3개 준비
kubectl apply -f k8s/deployment.yaml

네트워킹의 전체 문제 구분은 쿠버네티스 네트워킹 모델, Service 구현은 Service와 kube-proxy에서 다뤘다.


NodePort

NodePort는 클러스터의 노드 포트를 통해 Service에 접근하게 하는 유형이다. 이 실습에서 앱은 파드 안 8083에서 수신하고, Service 포트는 80, 노드 포트는 30420이다.

매니페스트와 세 포트

# k8s/node-port-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: joker-nodeport-service
spec:
  type: NodePort
  ports:
    - targetPort: 8083
      port: 80
      nodePort: 30420
  selector:
    app: fast-api
필드 값 역할
port 80 Service의 클러스터 IP로 접근할 때 사용하는 포트
targetPort 8083 선택된 파드에서 앱이 수신하는 포트
nodePort 30420 노드 주소를 통해 접근할 때 사용하는 포트

NodePort로 들어온 패킷은 노드의 전달 규칙을 통해 파드의 8083으로 향한다. Service 포트 80에서 별도 프로세스가 패킷을 받아 다시 보내는 것은 아니다. 포트 역할을 혼동했던 사례는 Service 포트와 노출 포트에 있다.

# Service 생성과 연결 대상 확인
kubectl apply -f k8s/node-port-service.yaml
kubectl get svc,ep joker-nodeport-service -owide

# 실행 결과
NAME                             TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE   SELECTOR
service/joker-nodeport-service   NodePort   10.96.39.173   <none>        80:30420/TCP   17s   app=fast-api

NAME                               ENDPOINTS                                            AGE
endpoints/joker-nodeport-service   10.244.0.22:8083,10.244.0.23:8083,10.244.0.24:8083   17s

svc는 Service, ep는 Endpoints의 단축 이름이다. 출력에는 같은 Deployment의 파드 세 개와 8083 포트가 연결 대상으로 나타났다.

Mac과 다른 PC에서의 호출

Mac의 Docker Desktop에서는 kind 노드가 Linux VM 안의 컨테이너로 실행된다. 3.3.1편의 extraPortMappings에는 호스트 30420 → 노드 컨테이너 30420 매핑이 있다.

flowchart LR
    %% Mac 포트 매핑과 NodePort 전달 경로
    M[Mac localhost:30420] --> D[Docker Desktop 포트 매핑]
    D --> N[노드 nodePort 30420]
    N --> R[Service의 kube-proxy 규칙]
    R --> P[파드 targetPort 8083]
    S[ClusterIP의 port 80] --> R
출처: 『머신러닝 플랫폼 엔지니어링』 그림 3.7. AI를 이용해 재구성했다. Mac의 포트 매핑과 두 접근 경로를 함께 나타냈다.

curl은 HTTP 요청을 보내는 도구다. Mac에서 포트 매핑을 통해 호출하자 농담 JSON이 반환됐다.

# Mac에서 NodePort 호출
curl -s http://localhost:30420

# 실행 결과
{"random_joke":"How do you generate a random string? Put a first year Computer Science student inVim and ask them to save and exit."}

다른 PC에서는 Mac의 LAN 주소로 요청했다. ifconfig는 네트워크 인터페이스의 주소와 상태를 보여 주는 도구다. 당시 Mac의 en0에서 확인한 IPv4 주소를 사용했다.

# ifconfig 출력 중 en0의 IPv4 주소
inet 192.168.0.10 netmask 0xffffff00 broadcast 192.168.0.255

jq는 JSON 출력의 구조를 보기 좋게 정렬한다.

# 같은 LAN의 다른 PC에서 호출
other-pc:~$ curl -s http://192.168.0.10:30420 | jq
{
  "random_joke": "What's the object-oriented way to become wealthy? Inheritance."
}

포트 매핑이 0.0.0.0에 바인딩되어 있어 LAN 인터페이스로도 요청을 받을 수 있었다. 같은 LAN에서 이 요청의 응답을 확인했으며, 다른 네트워크에서의 도달성은 시험하지 않았다. 호출 확인 뒤 kubectl delete -f k8s/node-port-service.yaml로 Service를 삭제했다.


ClusterIP

ClusterIP는 클러스터 내부 접근에 사용하는 Service 유형이다. 클라이언트는 가상 IP와 Service 포트로 요청하고, 노드의 네트워크 규칙이 이를 백엔드로 전달한다. 이번 Mac 환경에서는 클러스터 안의 임시 파드와 kind 노드에서 호출했다.

IP와 서비스명 호출

# k8s/cluster-ip-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: joker-cluster-ip-service
spec:
  type: ClusterIP
  ports:
    - targetPort: 8083
      port: 80
  selector:
    app: fast-api
# Service 생성과 주소 확인
kubectl apply -f k8s/cluster-ip-service.yaml
kubectl get svc,ep joker-cluster-ip-service -owide

# 실행 결과 중 Service
NAME                               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE   SELECTOR
service/joker-cluster-ip-service   ClusterIP   10.96.179.55   <none>        80/TCP    10s   app=fast-api

# curl이 들어 있는 임시 파드의 셸 실행
kubectl run curl-test --rm -it --image=curlimages/curl:latest -- sh

kubectl run은 파드를 생성한다. 이 명령은 셸에 연결하고, 종료 후 --rm에 따라 파드를 지운다. 파드 안에서 http://10.96.179.55:80과 http://joker-cluster-ip-service로 각각 호출해 앱 응답을 받았다. HTTP 기본 포트가 80이어서 서비스명 호출에서는 포트를 생략할 수 있다.

# 임시 파드 안에서 서비스명 호출
~$ curl -s http://joker-cluster-ip-service
{"random_joke":"Why don't jokes work in octal? Because 7 10 11."}

별도 조회 시점의 Service IP는 10.96.3.3이었다. 클러스터 IP는 Service 오브젝트가 유지되는 동안 안정적으로 쓰는 주소이며, 삭제 후 다시 만들면 달라질 수 있다. docker exec는 실행 중인 컨테이너 안에서 명령을 실행한다. kind 노드 컨테이너에서도 그 주소를 호출했다.

# 해당 시점의 주소를 조회한 뒤 노드에서 요청
kubectl get service joker-cluster-ip-service
docker exec ml-study-control-plane curl -s http://10.96.3.3:80

# curl 실행 결과
{"random_joke":"The best thing about a Boolean is that even if you are wrong, you are only off by a bit."}

CoreDNS 이름 해석

CoreDNS는 클러스터의 DNS 질의를 처리하는 서버다. kubelet은 파드의 DNS 정책과 클러스터 설정에 따라 컨테이너의 /etc/resolv.conf를 구성한다. 임시 파드에서 확인한 내용은 다음과 같다.

# 파드의 /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

10.96.0.10은 kubectl -n kube-system get svc kube-dns로 확인한 DNS Service의 ClusterIP다. search는 짧은 이름에 덧붙일 검색 도메인이고, ndots:5는 이름의 점 개수에 따른 검색 순서에 영향을 준다. 보통 같은 네임스페이스의 joker-cluster-ip-service는 joker-cluster-ip-service.default.svc.cluster.local로 확장해 찾는다. 완전한 도메인 이름(FQDN)을 직접 쓰거나 다른 네임스페이스의 서비스명.네임스페이스 형태를 사용할 수도 있다. Kubernetes DNS 문서

CoreDNS의 kubernetes 플러그인은 API 서버에서 Service와 EndpointSlice 등의 변화를 감시한다. 일반 Service의 IPv4 A 레코드는 ClusterIP를, clusterIP: None인 헤드리스(headless) Service는 조건에 맞는 백엔드 주소들을 반환한다. DNS 요청 자체도 kube-dns Service 주소를 거쳐 CoreDNS 파드로 전달된다. CoreDNS 플러그인 문서

다만 별도로 실행한 DNS 조회 명령 nslookup에는 다음 실패가 남았다. NXDOMAIN은 질의한 이름이 존재하지 않는다는 DNS 응답이다.

# nslookup joker-cluster-ip-service 출력 발췌
Server:         10.96.0.10
Address:        10.96.0.10:53
** server can't find joker-cluster-ip-service.default.svc.cluster.local: NXDOMAIN

서비스명으로 한 curl 호출은 성공했지만, 이 DNS 조회 시점의 Service 상태와 CoreDNS 로그를 함께 확보하지 않아 실패 원인은 확인하지 못했다. FQDN을 직접 호출한 응답 기록도 없다. 재확인하려면 Service가 존재하는 같은 시점에 DNS 질의와 CoreDNS 로그를 함께 확인해야 한다. 검색 도메인의 상세 동작은 ndots와 search 설정, DNS 장애 조사 사례는 CoreDNS의 간헐적 레지스트리 이름 해석 실패에서 다뤘다.

kube-proxy의 iptables DNAT

kube-proxy는 Service와 EndpointSlice 변화를 감시해 노드에 전달 규칙을 구성하는 컴포넌트다. iptables는 Linux 커널의 패킷 처리 규칙을 관리하는 도구이며, DNAT(Destination NAT)는 패킷의 목적지 주소와 포트를 바꾸는 동작이다.

실습 클러스터의 kube-proxy ConfigMap에서 mode: iptables를 확인했다. 이 모드에서는 패킷이 커널의 규칙을 따라 처리된다. Service 가상 IP 구현

grep은 일치하는 문자열이 있는 줄을 추리고, iptables-save는 현재 규칙을 출력한다. -t nat은 주소 변환을 다루는 테이블을 지정한다.

# ConfigMap의 모드 조회와 nat 테이블 확인
kubectl -n kube-system get cm kube-proxy -o yaml | grep mode
docker exec ml-study-control-plane iptables-save -t nat | grep joker

이때 Service IP는 10.96.3.3, 엔드포인트는 10.244.0.22·.23·.24의 세 개였다.

Service 선택과 DNAT 규칙 발췌
# Service 주소와 포트를 매칭
-A KUBE-SERVICES -d 10.96.3.3/32 -p tcp -m comment --comment "default/joker-cluster-ip-service cluster IP" -m tcp --dport 80 -j KUBE-SVC-LG72KV2OS4S5IKG3
# 백엔드 선택
-A KUBE-SVC-LG72KV2OS4S5IKG3 -m comment --comment "default/joker-cluster-ip-service -> 10.244.0.22:8083" -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-QSQ5Q5CFT74RM336
-A KUBE-SVC-LG72KV2OS4S5IKG3 -m comment --comment "default/joker-cluster-ip-service -> 10.244.0.23:8083" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-FWKPPZ6JL4VOSDU3
-A KUBE-SVC-LG72KV2OS4S5IKG3 -m comment --comment "default/joker-cluster-ip-service -> 10.244.0.24:8083" -j KUBE-SEP-X4B4INJNZCYRIMJC
# 선택한 파드의 IP와 포트로 목적지 변환
-A KUBE-SEP-QSQ5Q5CFT74RM336 -p tcp -m comment --comment "default/joker-cluster-ip-service" -m tcp -j DNAT --to-destination 10.244.0.22:8083
-A KUBE-SEP-FWKPPZ6JL4VOSDU3 -p tcp -m comment --comment "default/joker-cluster-ip-service" -m tcp -j DNAT --to-destination 10.244.0.23:8083
-A KUBE-SEP-X4B4INJNZCYRIMJC -p tcp -m comment --comment "default/joker-cluster-ip-service" -m tcp -j DNAT --to-destination 10.244.0.24:8083

규칙의 연결은 KUBE-SERVICES → KUBE-SVC-… → KUBE-SEP-… → DNAT다. 첫 번째 엔드포인트는 1/3 확률로 선택한다. 거기서 선택되지 않은 연결의 1/2이 두 번째로 가고, 나머지가 세 번째로 간다. 결과적으로 각 엔드포인트의 선택 확률은 1/3이다. NodePort 요청은 KUBE-NODEPORTS에서 Service의 KUBE-SVC-… 체인으로 연결된다.

conntrack은 커널이 연결 상태와 NAT 매핑을 추적하는 기능이다. 같은 TCP 연결의 후속 패킷은 처음 선택한 파드로 전달되고, 응답에는 역방향 주소 변환이 적용된다. HTTP keep-alive로 같은 연결을 재사용하면 그 연결 안에서는 같은 파드가 요청을 받는다. 규칙과 주소 변환 구조는 확인했지만, 파드별 응답 횟수나 TCP 연결 재사용에 따른 분포는 측정하지 않았다. 커널 처리 과정은 iptables와 conntrack에서 다뤘다.

ClusterIP와 ping

이번 iptables 모드의 ClusterIP는 인터페이스에 할당한 주소가 아니라 전달 규칙에서 사용하는 가상 주소다. 위 규칙은 -p tcp --dport 80을 매칭하므로 ICMP Echo 요청인 ping은 이 Service 전달 경로를 시험하지 못한다. HTTP Service의 동작은 해당 포트로 요청해 확인하는 것이 맞다.

IPVS(IP Virtual Server)는 Linux 커널의 전송 계층 부하 분산 기능이다. kube-proxy의 IPVS 모드는 서비스 IP를 kube-ipvs0이라는 더미 인터페이스에 바인딩한다. 따라서 “ClusterIP는 항상 인터페이스에 없다”는 설명을 모든 모드에 적용할 수는 없다. v1.29.14 IPVS 구현 이번 실습에서는 ping 결과나 IPVS 모드 전환을 확인하지 않았다.


LoadBalancer

LoadBalancer는 외부 로드 밸런서와 연동해 Service를 노출하는 유형이다. 외부 주소의 할당과 트래픽 전달은 클러스터에 설치된 구현이 맡는다.

flowchart TD
    %% 외부 LB가 제공되는 환경의 논리적 접근 경로
    C[외부 클라이언트] --> L[외부 로드 밸런서]
    L --> S[LoadBalancer Service]
    S --> P1[파드 1]
    S --> P2[파드 2]
    S --> P3[파드 3]
출처: 『머신러닝 플랫폼 엔지니어링』 그림 3.8. AI를 이용해 재구성했다
# k8s/loadbalancer-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: joker-load-balancer-service
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 8083
  selector:
    app: fast-api

스터디 노션의 K3s 결과에는 노드 주소가 EXTERNAL-IP에 표시됐다. K3s에는 ServiceLB가 기본 포함되며, klipper-lb 컨테이너를 사용하는 파드로 Service 포트를 노출한다. K3s ServiceLB 문서 내 kind 환경의 결과는 아래와 같았다.


트러블슈팅

증상

LoadBalancer Service를 생성했지만 EXTERNAL-IP가 <pending>으로 남았다.

증거

# Service 생성 후 주소와 엔드포인트 조회
kubectl apply -f k8s/loadbalancer-service.yaml
kubectl get svc,ep joker-load-balancer-service -owide

# 실행 결과
NAME                                  TYPE           CLUSTER-IP   EXTERNAL-IP   PORT(S)        AGE   SELECTOR
service/joker-load-balancer-service   LoadBalancer   10.96.93.9   <pending>     80:31089/TCP   34s   app=fast-api

NAME                                    ENDPOINTS                                            AGE
endpoints/joker-load-balancer-service   10.244.0.22:8083,10.244.0.23:8083,10.244.0.24:8083   34s

Service와 파드 엔드포인트는 생성됐다. 실습 메모에는 K3s의 klipper-lb 파드도 생성되지 않았다고 기록돼 있다.

원인

이번 kind 기본 구성에는 LoadBalancer의 외부 주소를 할당하고 구현할 컨트롤러가 없다. type: LoadBalancer 선언만으로 외부 로드 밸런서가 생기는 것은 아니다. K3s ServiceLB, 클라우드의 로드 밸런서 컨트롤러처럼 선언을 처리할 구현이 필요하다.

해결

앱에 접근하는 방법으로 실습 메모에 적은 것은 port-forward다. API 서버를 통해 선택한 파드의 8083으로 연결하므로 외부 LB 할당과 별개로 앱을 호출할 수 있다. 3.3.3편의 port-forward 동작 원리에서 이 경로를 다뤘다.

# Service가 존재할 때 실행할 접근 명령
kubectl port-forward svc/joker-load-balancer-service 8080:80

# 다른 터미널에서 호출
curl -s localhost:8080

이 Service를 대상으로 한 포워딩 응답 출력은 남아 있지 않고, 외부 LB 구현도 추가로 설치하지 않았다. 따라서 <pending> 상태를 해소한 결과는 없다.

kind용 외부 LB 구현으로는 cloud-provider-kind가 있다. 호스트에서 실행해 LB 컨테이너를 관리하며, 공식 README에 바이너리 설치와 macOS 실행 조건이 안내돼 있다. EKS의 외부 LB 연동은 Service와 LoadBalancer에서 다뤘다.


정리

유형 접근 주소 이번 kind on Mac에서의 확인 방법
ClusterIP 클러스터 IP와 Service 포트 임시 파드와 노드에서 HTTP 호출
NodePort 노드 주소와 nodePort 호스트 30420 매핑으로 Mac과 LAN PC에서 호출
LoadBalancer 구현이 할당하는 외부 주소 <pending>과 엔드포인트 확인, 포워딩 접근 명령 기록

ML 플랫폼에서는 서빙 API, 피처 저장소, 실험 추적 서버처럼 서로 호출해야 하는 서비스가 많다. 파드의 교체와 확장은 워크로드 컨트롤러가 관리하고, 호출 주소와 백엔드 연결은 Service가 담당한다. 이 실습은 그 서비스 연결 계층을 확인한 과정이다.


참고 링크




hit count

댓글남기기