쿠버네티스에서 파드에 직접 접근하려면 kubectl describe로 파드 IP를 확인한 뒤 그 IP로 접속하면 된다. 이 방식은 클러스터 내부에서 동작 여부를 빠르게 확인할 때만 사용이 가능하다.
파드 IP는 영속적이지 않다. 파드가 재시작되거나 다른 노드로 재스케줄링되는 순간 새 IP를 할당받으므로, 한 번 확인한 IP가 무효가 될 수 있다.
또한 여러 디플로이먼트를 하나의 애플리케이션으로 엮을 때 서로의 파드 IP를 미리 알 방법이 없다. 예를 들어 프론트 파드에서 백엔드 파드를 호출하려면 그때그때 바뀌는 IP를 추적하는 게 아니라 변하지 않는 식별자로 서로를 발견할 수 있어야 한다.
이러한 이유로 쿠버네티스는 파드 IP를 통신 경로로 직접 사용하도록 권장하지 않고, 별도의 식별 계층인 서비스(Service) 오브젝트를 둔다.
도커에서는 컨테이너 생성과 외부 노출이 한 명령에 묶여 있다. docker run -p 8080:80을 실행하면 컨테이너가 만들어지는 그 순간 호스트 포트가 컨테이너 포트로 연결된다.
컨테이너 사이의 내부 통신도 비슷하다. 사용자 정의 브리지 네트워크에 같이 올리면 도커의 내장 DNS가 컨테이너 이름을 IP로 풀어준다. 즉, 노출과 발견 모두 컨테이너 자체의 실행 옵션으로 다룬다.
쿠버네티스는 이 둘을 의도적으로 분리한다. 디플로이먼트 YAML에 적는 containerPort는 파드의 컨테이너가 어떤 포트를 사용한다는 정보 표기에 가깝고, 그 자체로는 외부에 포트를 열지도, 클러스터 내부에서 다른 파드가 부를 안정된 이름을 만들어 주지도 않는다.
외부로 노출하거나 다른 디플로이먼트가 일관된 이름으로 호출하게 하려면, 파드와는 별개의 오브젝트인 서비스(Service)를 따로 정의해야 한다. 노출과 발견을 파드 정의에서 떼어낸 구조 덕분에, 같은 파드를 환경마다 다른 방식으로 노출하거나 노출 방식만 단독으로 교체하는 운영이 가능해진다.
서비스는 파드 집합 위에 얹히는 식별/라우팅 계층이다. 파드 자체는 계속 바뀌지만, 그 위에서 서비스가 세 가지 일을 맡는다.
먼저 안정된 도메인 이름을 부여한다. 클러스터 DNS가 서비스 이름을 가상 IP(ClusterIP)로 해석해 주기 때문에, 다른 파드는 변하는 파드 IP가 아닌 <service>.<namespace> 형태의 이름으로 서비스를 호출한다.
또한 요청을 여러 파드에 분산하는 로드 밸런서 역할도 한다. 같은 라벨을 공유하는 파드 여러 개가 한 서비스 뒤에 묶이면, 서비스로 들어온 트래픽이 그중 하나의 파드로 라우팅된다.
파드의 노출 범위도 지정하는데, 클러스터 내부에서만 열지, 혹은 모든 노드의 특정 포트로 열지, 클라우드 플랫폼의 로드 밸런서를 통해 공인 IP로 열지를 서비스 정의 안에서 선택한다.
서비스는 파드를 어디까지 노출할지에 따라 세 가지 타입으로 나뉜다.
ClusterIP는 기본 타입이며 클러스터 내부에서만 접근 가능한 가상 IP를 부여한다. 외부에서는 이 IP로 도달할 수 없기 때문에, 클러스터 안의 다른 파드끼리만 호출하는 내부 서비스에 적합하다.
NodePort는 클러스터의 모든 노드에 동일한 포트를 열고, 그 포트로 들어온 트래픽을 서비스의 ClusterIP로 전달한다. 포트는 기본 30000~32767 범위에서 자동 할당되며, nodePort 필드로 직접 지정할 수도 있다.
LoadBalancer는 클라우드 플랫폼의 로드 밸런서를 동적으로 프로비저닝해 외부 IP로 트래픽을 받는다. 내부적으로는 NodePort와 ClusterIP를 함께 만들어 두고 그 위에 클라우드 LB를 얹는 구조이며, AWS나 GCP 같은 클라우드 환경에서 별도 설정 없이 동작한다.
다음은 라벨이 app: bluecool인 파드들에 클러스터 내부 IP로 접근하게 만드는 YAML이다.
apiVersion: v1
kind: Service
metadata:
name: clusterip-service
spec:
type: ClusterIP
selector:
app: bluecool
ports:
- port: 8080
targetPort: 8080
spec.selector는 어떤 라벨을 가진 파드를 이 서비스 뒤에 묶을지 결정한다. 위 예시에서는 app: bluecool 라벨이 붙은 파드들이 라우팅 대상이 된다. 라벨은 단순한 표시용 메타데이터를 넘어, 서비스나 레플리카셋처럼 라벨로 리소스를 묶는 컨트롤러에서는 일치 여부 자체가 동작 조건이 된다. spec.ports.port는 서비스의 ClusterIP에 접근할 때 사용할 포트로, spec.ports.targetPort는 셀렉터로 묶인 파드가 실제로 listen하는 포트다. 위 YAML은 서비스의 가상 IP의 8080 포트로 들어온 요청을 라벨이 매칭된 파드의 8080 포트로 전달한다는 의미이다. 두 값이 같을 필요는 없으며, 컨테이너가 실제로 받는 포트와 targetPort만 일치하면 된다. kubectl apply -f로 적용한 뒤, kubectl get svc를 실행하면 CLUSTER-IP 컬럼에 가상 IP가 할당된 것이 보인다. 같은 클러스터의 파드라면 이 IP의 8080 포트로 직접 호출할 수 있고, 더 흔하게는 서비스 이름으로 호출이 가능하다.
쿠버네티스는 기본적으로 클러스터 DNS(CoreDNS)를 구동하고 모든 파드를 자동으로 이 DNS에 묶기 때문에, 같은 네임스페이스의 다른 파드에서는 curl clusterip-service:8080 한 줄이면 응답이 돌아온다.
kubectl get svc결과를 처음 본다면default네임스페이스의kubernetes라는 ClusterIP 서비스가 함께 보여 의아할 수 있다. 이는 클러스터 부트스트랩 시점에 자동으로 생성되는 kube-apiserver 진입용 서비스다. 파드 안에서 쿠버네티스 API를 호출할 때 쓰인다.
NodePort는 ClusterIP의 기능을 그대로 하면서, 추가로 클러스터의 모든 노드에 같은 포트를 열어준다. YAML도 ClusterIP에서 type만 바꾸면 된다.
apiVersion: v1
kind: Service
metadata:
name: nodeport-service
spec:
type: NodePort
selector:
app: bluecool
ports:
- port: 8080
targetPort: 8080
kubectl apply -f 후 kubectl get svc를 확인해보면 두 가지가 눈에 띈다. PORT(S) 컬럼이 8080:31514/TCP 같은 두 숫자 형태로 출력된다. 앞의 8080은 ClusterIP의 포트, 뒤의 31514가 모든 노드에 열린 NodePort이다.
추가로 눈여겨볼 부분은 타입이 NodePort인데도 CLUSTER-IP 컬럼에 가상 IP가 채워져 있다는 점이다. 이는 NodePort 서비스가 ClusterIP의 동작을 그대로 포함하기 때문이다. 클러스터 안에서는 여전히 서비스 이름과 가상 IP로 호출할 수 있고, 클러스터 밖에서는 노드 IP의 31514 포트로 추가로 도달할 수 있다.
외부에서 접근할 때는 kubectl get nodes -o wide로 노드 IP를 확인한 뒤 curl <노드 IP>:31514를 호출한다. 어느 노드의 IP를 골라도 결과는 같다. 같은 NodePort가 모든 노드에 열려 있고, 어느 노드로 들어온 요청이든 kube-proxy가 셀렉터에 매칭된 파드 중 하나로 라우팅한다.
NodePort에 쓰일 포트는 기본적으로 30000~32767 범위에서 자동 할당되지만, YAML의 nodePort 필드로 직접 지정할 수도 있다. 범위 자체를 바꾸려면 kube-apiserver의 --service-node-port-range 옵션을 수정한다. (다른 시스템 서비스와 충돌 주의)
클라우드 환경에서는 노드 포트가 그냥 열리지 않는다. GKE에서는 VPC 방화벽 규칙을, AWS에서는 노드의 보안 그룹 인바운드 규칙을 별도로 추가해야 외부에서 NodePort에 도달할 수 있다. 누락 시 클러스터 내부에서는 멀쩡히 동작하는데 외부 curl만 타임아웃이 발생할 수 있다.
운영 환경에서 NodePort를 단독으로 외부 노출에 쓰는 경우는 드물다. 80, 443 같은 표준 포트를 NodePort 범위에 매핑하기가 부자연스럽고, TLS 종료나 호스트/경로 기반 라우팅을 서비스 리소스 한 개 안에서 표현하기 어렵기 때문이다.
외부 노출은 보통 Ingress나 LoadBalancer가 맡고, NodePort는 그 안쪽에서 트래픽을 받아 간접적으로 쓰이는 경우가 많다.
만약 같은 클라이언트의 요청을 매번 같은 파드로 보내고 싶다면 서비스의 spec.sessionAffinity를 ClientIP로 둔다. 클라이언트의 출발지 IP를 기준으로 같은 파드에 라우팅되며, 기본값은 분산 대상 파드 중 어느 쪽으로도 라우팅될 수 있는 None이다. 이 옵션은 NodePort에 한정되지 않고 모든 서비스 타입에 적용된다.
NodePort는 외부 도달을 가능하게 하지만, 클라이언트가 노드 IP를 직접 알아야 한다는 부담이 남는다. LoadBalancer는 클라우드 플랫폼의 로드 밸런서를 자동으로 띄우고, 그 LB의 외부 IP나 도메인을 통해 트래픽을 받게 한다.
YAML은 NodePort에서 type과 외부 노출 포트만 바꿔주면 된다.
apiVersion: v1
kind: Service
metadata:
name: lb-service
spec:
type: LoadBalancer
selector:
app: bluecool
ports:
- port: 80
targetPort: 8080
port: 80은 클라우드 LB가 외부에 노출하는 포트이고, targetPort: 8080은 파드의 컨테이너가 listen 하는 포트다. kubectl apply -f 후 kubectl get svc를 확인해보면 세 가지 정보가 한 줄에 들어 있다. CLUSTER-IP에는 여느 서비스처럼 가상 IP가 할당돼 있고, 클러스터 안에서는 서비스 이름이나 이 IP로 호출할 수 있다. EXTERNAL-IP에는 클라우드 플랫폼이 새로 만든 로드 밸런서의 외부 IP(또는 도메인 이름)가 채워진다. 이 주소의 80 포트로 들어오는 외부 트래픽이 서비스로 들어온다. PORT(S)에는 80:32620/TCP처럼 두 숫자가 보이는데, 뒤의 32620은 LoadBalancer가 함께 만들어 둔 NodePort이다. 노드 IP의 32620 포트로 접근하면 NodePort 서비스와 똑같이 동작한다.
세 경로가 동시에 열린 것은 LoadBalancer가 NodePort와 ClusterIP를 그대로 포함하기 때문이다. 실제 외부 트래픽은 클라우드 LB가 받아 워커 노드 중 하나의 32620 포트로 전달하고, 노드에 들어온 요청을 kube-proxy가 파드 중 하나로 라우팅한다.
LoadBalancer는 클라우드 LB를 만들어 줄 컨트롤러가 클러스터에 있어야 동작한다. AWS, GCP, Azure 같은 매니지드 환경에서는 자동으로 동작하지만, 베어메탈이나 일반 VM 클러스터에서는 LB를 만들어 줄 주체가 없어 EXTERNAL-IP가 <pending>에 머무른다.
이런 환경에서 가장 흔히 쓰는 보완책이 MetalLB이다. 미리 정의해 둔 IP 풀에서 외부 IP를 직접 할당하고, 그 IP가 노드로 향하도록 외부 네트워크에 알려 주는 방식이라, 클라우드 LB 없이도 LoadBalancer 타입을 그대로 쓸 수 있다.
같은 LoadBalancer 타입 안에서도 어떤 종류의 LB를 만들지는 metadata.annotations로 결정한다. 예를 들어 AWS 인-트리 클라우드 프로바이더 환경의 기본은 클래식 로드 밸런서(CLB)이고, NLB로 바꾸려면 서비스에 service.beta.kubernetes.io/aws-load-balancer-type: nlb 어노테이션을 추가한다.
어노테이션은 라벨과 비슷하게 키-값 쌍이지만 목적이 다르다. 라벨이 셀렉터로 리소스를 묶기 위한 것이라면, 어노테이션은 컨트롤러나 도구에 전달하는 메타정보다.
NodePort나 LoadBalancer로 외부 트래픽을 받을 때, 그 트래픽이 어느 노드를 거쳐 어느 파드에 도달할지를 결정하는 속성이 spec.externalTrafficPolicy이다. 값은 두 가지뿐이고 기본은 Cluster다.
직접 만든 서비스를 kubectl get svc -o yaml로 확인해보면 이 필드가 자동으로 채워져 있는 것을 확인할 수 있다. -o yaml은 사용자가 명시하지 않은 항목까지 쿠버네티스가 채운 모든 속성을 보여 주므로, 기본값을 확인할 때 자주 쓰는 옵션이다. Cluster는 어느 노드로 들어온 트래픽이든 셀렉터에 매칭된 클러스터 전체의 파드 중 임의의 하나로 보낸다. 트래픽을 받은 노드와 파드가 다른 노드에 있다면 노드 간 한 홉이 추가되고, 이때 첫 노드는 source IP를 자기 IP로 바꿔 응답이 자기에게 돌아오게 만든다. 이 SNAT 때문에 파드의 애플리케이션은 원래 클라이언트의 IP를 볼 수 없게 된다.
반면 Local로 설정하면 트래픽을 받은 노드는 같은 노드에 위치한 파드로만 요청을 라우팅하고, 다른 노드로 넘기지 않는다. 노드 간 홉이 사라지므로 SNAT을 적용할 이유도 없어, 파드는 클라이언트의 원래 IP를 그대로 본다.
LoadBalancer 환경이라면 클라우드 LB의 헬스체크가 파드 없는 노드를 탈락시키기 때문에 트래픽이 자연스럽게 파드 있는 노드로만 흘러 들어간다. NodePort 단독 환경에서는 파드 없는 노드로 들어온 트래픽이 그냥 드롭되므로, 호출 측이 파드의 위치를 알고 있어야 한다.
YAML에서는 한 줄만 추가하면 된다.
apiVersion: v1
kind: Service
metadata:
name: lb-service
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: bluecool
ports:
- port: 80
targetPort: 8080
다만 Local로 설정하는 경우, 트래픽이 노드 안에서만 분산되므로 노드별로 파드 수가 다르면 파드 한 개당 받는 부하가 어긋난다. 노드 A에 파드 3개, 노드 B에 파드 1개라면 LB가 두 노드에 트래픽을 균등하게 보냈을 때 B의 파드는 A의 파드 한 개보다 3배 많은 요청을 받는다.
이 불균형은 스케줄링 단계에서 파드를 노드에 고르게 배치해 어느 정도 해소할 수 있다. 디플로이먼트의 topologySpreadConstraints나 PodAntiAffinity로 노드별 분포를 강제하는 방식이 일반적이다. 그렇지만 완전한 균등은 어려우므로, 파드의 애플리케이션이 클라이언트 IP를 정말로 필요로 하는가를 기준으로 Cluster와 Local 중 하나를 고르는 편이 실용적이다.
ExternalName은 다른 서비스 타입과 결이 다르다. 셀렉터로 묶을 파드도, 트래픽을 보낼 ClusterIP도, 매핑을 보관할 Endpoints도 만들지 않는다. 대신 클러스터 DNS에 외부 도메인 이름의 별명을 등록한다.
클러스터 안의 파드가 이 서비스 이름을 호출하면 DNS가 외부 도메인을 응답으로 돌려주고, 실제 통신은 그 외부 도메인으로 직접 일어난다.
쿠버네티스 외부의 레거시 데이터베이스나 SaaS API처럼 클러스터가 직접 관리하지 않는 시스템을 클러스터 안의 서비스처럼 부르고 싶을 때 사용한다.
apiVersion: v1
kind: Service
metadata:
name: externalname-svc
spec:
type: ExternalName
externalName: my.database.com
위와 같은 매니페스트를 적용하면 클러스터 DNS에 externalname-svc.<namespace>.svc.cluster.local이 my.database.com으로 향하는 CNAME 레코드가 등록된다. 같은 네임스페이스의 파드는 externalname-svc라는 이름만으로 외부 DB에 접근할 수 있고, 외부 DB의 도메인이 바뀌면 이 매니페스트의 externalName 한 줄만 고치면 된다.
CNAME 레코드는 도메인을 다른 도메인으로 가리키는 별명이다. 같은 자리에서 도메인을 IP로 직접 해석해 주는 것은 A 레코드인데, ExternalName이 만드는 것은 IP가 아니라 도메인이라는 점에서 항상 CNAME이다. 따라서 externalName 값에는 IP 주소를 넣을 수 없으며, 반드시 DNS로 해석 가능한 도메인이어야 한다.
