서비스: 클라이언트와 파드 연결
- 5.1 Service 소개
- 5.2 클러스터 외부 서비스에 연결하기
- 5.3 Service를 외부 클라이언트에 노출하기
- 5.4 Ingress로 외부 노출
- 5.5 Readiness Probe — Pod가 요청 받을 준비가 됐나
- 5.6 Headless Service로 개별 Pod 찾기
- 5.7 Service 트러블슈팅
5장은 한 마디로 "클라이언트는 Pod를 어떻게 찾고, Pod는 다른 Pod를 어떻게 찾는가"에 대한 장이야. Pod는 태어나고 죽고 옮겨다니면서 IP가 계속 바뀌거든. 이런 움직이는 타깃을 클라이언트가 직접 추적하는 건 말이 안 되니까, 쿠버네티스는 Service라는 안정적인 진입점을 제공해. 이 장에서는 Service의 기본부터 외부 노출(NodePort, LoadBalancer, Ingress), readiness probe, headless service, 그리고 트러블슈팅까지 다 다뤄.
먼저 Pod를 직접 붙잡고 얘기하는 게 왜 안 되는지부터 짚자. 첫째 Pod는 ephemeral해 — 언제든 사라지고 옮겨져. 둘째 Pod IP는 스케줄링 후에 할당되니까 시작 전엔 알 수 없어. 셋째 수평 확장 때문에 같은 서비스를 여러 Pod가 제공해서, 클라이언트는 "어느 Pod 몇 개"가 아니라 하나의 주소만 알면 되어야 해. Service는 이 문제들을 안정적인 IP + 포트로 해결해. 서비스 IP는 생성 후 변하지 않고, 뒤에 붙은 Pod가 교체돼도 그대로야. 어느 Pod가 서비스에 속하느냐는 label selector로 결정돼. 3장에서 RC가 썼던 그 셀렉터 메커니즘이 여기서도 그대로 쓰여.
Service YAML은 spec.ports에 port(서비스가 받는 포트)와 targetPort(뒤의 Pod가 듣는 포트)를 적고, selector로 어떤 Pod를 묶을지 지정해. 이렇게 만들면 클러스터 안에서 쓸 수 있는 ClusterIP가 하나 배정돼. 이 IP는 클러스터 내부에서만 의미가 있고 외부에선 안 닿아. 테스트할 때 자주 만나는 재밌는 디테일이 하나 있어 — 서비스 IP는 ping이 안 돼. 실체가 있는 IP가 아니라 가상 IP고, kube-proxy가 iptables 규칙으로 "이 IP:포트로 가는 패킷은 Pod 중 하나로 돌려라"를 걸어놓은 것뿐이거든. 그래서 curl은 되는데 ping은 안 돼서 당황하는 사람이 많아.
세션 어피니티는 기본값이 매 커넥션마다 랜덤한 Pod로 가는 거야. 같은 클라이언트의 요청을 같은 Pod로 고정하고 싶으면 sessionAffinity: ClientIP. 쿠버네티스는 쿠키 기반 어피니티는 지원하지 않아 — 서비스는 L4(TCP/UDP)에서 동작하기 때문에 HTTP 레이어의 쿠키를 모르거든. 그리고 한 서비스로 여러 포트를 노출할 수도 있어 (HTTP 80, HTTPS 443 동시). 이때 각 포트에 이름을 줘야 하는데, Pod 쪽에서 포트에 이름을 붙여두면 서비스 쪽에선 targetPort에 숫자 대신 이름을 쓸 수 있어. 그러면 나중에 Pod의 포트 번호를 바꿔도 서비스 YAML은 안 건드려도 돼.
Pod 안에서 다른 서비스를 어떻게 찾는지도 중요해. 두 가지 방법이 있어. 첫째는 환경변수야. Pod가 시작될 때 Kubelet이 당시 존재하던 서비스들을 KUBIA_SERVICE_HOST, KUBIA_SERVICE_PORT 같은 환경변수로 주입해. 한계가 있는데 — Pod보다 나중에 만들어진 서비스는 못 봐. 둘째는 DNS야. 모든 클러스터에 kube-dns(또는 CoreDNS) Pod가 도는데, 서비스를 만들면 자동으로 DNS 엔트리가 생겨. FQDN은 <service>.<namespace>.svc.cluster.local이고, 같은 네임스페이스면 <service>만 써도 돼. 이게 실전에서 쓰는 방법이야. DNS가 동작하는 건 Kubelet이 Pod의 /etc/resolv.conf를 클러스터 DNS를 가리키도록 세팅해주기 때문이야.
반대 방향, 그러니까 Pod가 클러스터 바깥의 서비스를 써야 할 때는 어떻게 할까? 외부 DB, 서드파티 API 같은 거. 먼저 이해해둬야 할 게 있어. Service는 Pod와 직접 연결된 게 아니라 Endpoints 객체를 거쳐가. 서비스에 selector를 달면 쿠버네티스가 자동으로 Endpoints를 만들어서 "이 서비스의 백엔드는 이 IP들이다"를 채워. kubectl get endpoints kubia로 확인할 수 있어. 이 구조 덕분에 selector 없는 서비스를 만들고 Endpoints를 직접 수동으로 관리할 수 있어. 그러면 서비스가 외부 IP들로 트래픽을 보내. 이렇게 하면 Pod 입장에선 내부 서비스처럼 쓰는데 실제로는 외부 서버로 트래픽이 나가. 나중에 그 외부 서비스를 쿠버네티스 안으로 이전할 때 서비스 정의는 안 건드리고 Endpoints만 바꾸면 되는 장점이 있어. 더 간단한 방법은 ExternalName 타입이야. 이건 쿠버네티스 DNS에 CNAME 레코드를 하나 만드는 것뿐이야. type: ExternalName에 externalName: someapi.somecompany.com을 적어두면 Pod가 그 서비스의 FQDN을 조회했을 때 DNS가 외부 도메인으로 돌려줘. 서비스 프록시를 완전히 우회해서 ClusterIP도 할당되지 않아.
외부에 노출하는 방법은 세 가지야. 첫째 NodePort. 서비스 타입을 NodePort로 하면 모든 워커 노드의 같은 포트를 열어서 거기로 들어오는 트래픽을 서비스로 포워딩해. 포트 번호는 지정 가능(30000~32767 범위)하거나 자동 할당이야. 이러면 <어느 노드의 IP>:30123으로 들어온 요청이 Pod 중 하나로 가. 어떤 노드 IP를 써도 상관없어. 문제는 노드 IP를 클라이언트가 알아야 하고, 어느 노드든 하나가 살아있어야 한다는 거야. 결국 앞단에 로드밸런서가 필요해져.
둘째 LoadBalancer는 NodePort의 확장판이야. 클라우드 프로바이더(AWS, GCP, Azure 등)가 전용 외부 IP의 로드밸런서를 자동으로 프로비저닝해서 앞단에 붙여줘. 클라이언트는 그 LB IP 하나만 알면 돼. 클라우드 환경이 아니면(Minikube 같은 데선) 외부 IP가 영원히 <pending>으로 남아. 외부 연결에 대한 주의점이 하나 있어 — externalTrafficPolicy. 기본값에선 노드 포트로 들어온 요청이 아무 Pod로나 가. 다른 노드의 Pod일 수도 있어서 한 hop 더 뛰는 셈이야. 이걸 Local로 바꾸면 해당 노드에 있는 Pod로만 보내. 불필요한 홉을 줄이고 클라이언트 IP를 보존할 수 있는 장점이 있지만, 노드 간 부하 분산이 고르지 않게 될 수 있어. A 노드에 Pod 1개, B 노드에 Pod 2개면 A에 오는 트래픽은 그 Pod 하나가 다 처리해야 하니까.
셋째 Ingress. 왜 Ingress가 필요하냐면, LoadBalancer는 서비스 하나당 외부 IP가 하나씩 잡혀. 서비스가 50개면 외부 IP가 50개 필요하고 비용도 50배야. Ingress는 하나의 IP로 여러 서비스를 HTTP 호스트/경로 기반으로 분기할 수 있어. 중요한 건 Ingress가 L7(HTTP)에서 동작한다는 거야. 그래서 쿠키 기반 세션 어피니티 같은 L4 서비스가 못 하는 것들도 지원해. 한 가지 알아둘 게, Ingress는 리소스일 뿐이고 실제로 일하는 건 Ingress Controller야. Nginx, HAProxy, Traefik 같은 컨트롤러가 Ingress 리소스를 읽고 자기 설정으로 반영해. 컨트롤러는 기본으로 안 깔려 있는 경우가 많아 — GKE는 구글 LB를 쓰고, Minikube는 minikube addons enable ingress로 활성화해야 해.
Ingress가 트래픽을 Pod에 전달하는 흐름은 이래. 클라이언트가 DNS로 호스트명을 조회해서 Ingress 컨트롤러 IP를 받고, 그 IP로 HTTP 요청을 보내면서 Host 헤더를 실어. 컨트롤러가 Host 헤더를 보고 어느 서비스로 보낼지 결정하는데, 여기가 재밌어 — 서비스를 거치지 않고 Endpoints에서 Pod IP를 직접 뽑아서 바로 Pod에 보내. 서비스는 Pod 목록을 얻는 조회용으로만 쓰여. 같은 호스트의 다른 경로를 다른 서비스로, 또는 다른 호스트를 다른 서비스로 매핑할 수 있어. 그리고 TLS 종단도 Ingress에서 처리해. 인증서와 키를 Secret에 넣고 Ingress에서 참조하면, 클라이언트와 Ingress 구간만 HTTPS고 Ingress와 Pod 사이는 평문이어도 돼. 앱이 TLS를 몰라도 되는 거지.
Pod가 생겨서 Running 상태라고 해서 바로 요청을 받을 수 있는 건 아니야. 초기화, 캐시 워밍업, 설정 로드 같은 게 남아있을 수 있거든. 이 시점에 서비스가 그 Pod로 트래픽을 보내면 클라이언트는 에러를 봐. Readiness Probe가 이 문제를 해결해. liveness probe와 비슷한 메커니즘이지만 목적이 완전히 달라. liveness probe 실패는 컨테이너 재시작으로 이어지지만, readiness probe 실패는 Pod가 Service의 Endpoints에서 제거되는 거야 (재시작은 안 함). 준비 되면 다시 추가되고. 세 종류는 liveness와 같아 — HTTP GET, TCP Socket, Exec.
실전 원칙 몇 가지가 있어. 항상 정의해. 없으면 Pod가 뜨자마자 서비스 엔드포인트에 들어가고, 아직 준비 안 된 Pod로 트래픽이 가서 "Connection refused"가 나. 그리고 Pod 종료 로직을 readiness probe에 넣지 마. Pod를 지우면 쿠버네티스가 알아서 모든 서비스에서 제거해주니까 probe로 따로 처리할 필요 없어. Pod를 서비스에서 수동으로 빼고 싶으면 probe를 조작하지 말고 라벨을 바꾸거나 Pod를 지워. 정리하면 liveness는 "이 프로세스 살아있어? 응답 못 하면 죽여줄게"고, readiness는 "지금 요청 받을 수 있어? 아니면 잠깐 트래픽 안 보낼게"야. 둘 다 정의하는 게 기본이고, readiness는 외부 의존성을 체크해도 되지만(프론트가 DB 못 붙으면 요청 안 받는 게 맞을 수 있으니까) liveness는 절대 외부 의존성을 체크하지 마. 4장에서 강조한 포인트.
일반 서비스는 하나의 가상 IP로 트래픽을 로드밸런싱해. 근데 클라이언트가 각 Pod를 직접 알고 싶을 때가 있어. 피어 투 피어 클러스터링(Cassandra, Elasticsearch 같은), StatefulSet 구성원 발견 같은 경우. 이때 headless service를 써. clusterIP: None으로 설정하면 ClusterIP가 할당되지 않고, 대신 DNS 조회 시 서비스 이름이 바로 Pod IP들로 해석돼 (여러 개의 A 레코드). nslookup kubia-headless를 Pod 안에서 하면 백엔드 Pod 수만큼 IP가 줄줄이 나와. 클라이언트는 그 IP들로 직접 연결할 수 있어. 여전히 DNS 라운드로빈 형태의 분산은 이뤄지지만, 클라이언트가 "전부에게 동시에 붙고 싶다"면 그것도 가능해져. 기본적으로는 ready인 Pod만 엔드포인트에 포함되는데, 준비 안 된 Pod까지 보고 싶으면 publishNotReadyAddresses: true를 쓰면 돼.
Service가 안 될 때 체크리스트. 클러스터 내부에서 ClusterIP로 붙어봐 (외부에서 ClusterIP는 안 닿아). ping 하지 마 — ClusterIP는 가상이라 안 돼. readiness probe가 성공 중인지 확인해 — 실패면 엔드포인트에 없어. kubectl get endpoints <svc>로 실제 엔드포인트가 있는지 확인하고, 비어있으면 selector가 잘못됐거나 매칭 Pod가 없거나 readiness가 실패한 거야. FQDN 대신 ClusterIP로 시도해서 DNS 문제인지 서비스 자체 문제인지 분리해. 서비스 포트(port)와 타겟 포트(targetPort) 헷갈리지 말고. Pod IP를 직접 찔러봐서 앱 자체가 살아있는지 확인하고, 마지막으로 앱이 localhost만 바인딩하고 있지 않은지 체크해. 0.0.0.0에 바인딩해야 외부에서 보여.
정리
5장 읽고 기억할 거 세 가지:
- Service는 안정적인 가상 IP + DNS 이름으로 Pod의 변동성을 숨겨주는 레이어다. 뒤에는 Endpoints 객체가 실제 Pod IP 목록을 들고 있고, kube-proxy가 iptables 규칙으로 트래픽을 분산한다. 그래서 ClusterIP는 ping은 안 되지만 커넥션은 된다.
- 외부 노출 수단은 L4(NodePort/LoadBalancer)와 L7(Ingress)로 나뉜다. 서비스가 많아지면 외부 IP 낭비 때문에 Ingress 하나로 호스트/경로 분기하는 게 정석이고, Ingress 컨트롤러는 서비스를 거치지 않고 Endpoints에서 Pod IP를 뽑아 직접 전달한다. TLS 종단도 Ingress에서 처리한다.
- Readiness probe는 반드시 정의해야 한다. 없으면 Pod가 준비되기 전에 트래픽이 들어가서 초기 요청이 실패한다. Liveness와 달리 readiness는 실패해도 컨테이너를 죽이지 않고 Endpoints에서 빼기만 한다 — 완전히 다른 역할이다.