소개
이제 애플리케이션을 배포하고 문제를 해결할 수 있지만, 클라이언트가 애플리케이션에 안정적으로 접근할 방법이 여전히 필요합니다. Pod IP 는 임시 주소입니다. Deployment 는 언제든지 Pod 를 교체할 수 있으며, 교체된 Pod 에는 다른 IP 주소가 할당됩니다.
Kubernetes 는 이 문제를 Service로 해결합니다. Service 는 계속 바뀌는 Pod 그룹을 선택하고, 클라이언트에는 하나의 안정적인 네트워크 식별자를 제공합니다. 이 실습에서는 레이블에서 EndpointSlice, 클러스터 DNS 까지 이어지는 전체 연결 경로를 살펴보고, 두 가지 Service 유형인 클러스터 내부 접근용 ClusterIP와 노드를 통한 접근용 NodePort를 사용합니다.
이 실습은 의도적으로 Service 까지만 다룹니다. Ingress 는 HTTP 라우팅과 별도의 컨트롤러를 추가하므로, 먼저 Service 의 선택 및 연결 가능성에 대한 개념을 확실히 이해한 뒤 학습하는 편이 좋습니다.
Service 백엔드 배포
환경 시작: 이 실습에서는 완전한 Kubernetes 클러스터가 시작됩니다. 컨트롤 플레인, 노드 및 네트워크 구성 요소를 설정하는 데 일반적으로 2~3분이 걸립니다. 환경 로딩이 완료될 때까지 잠시 기다린 후 작업을 시작하세요.
이 단계에서는 이후 Service 가 노출할 복제된 애플리케이션을 생성합니다. 여기서 백엔드란 Service 로 들어오는 트래픽을 수신할 수 있는 Pod 를 의미합니다.
작업 디렉터리로 이동합니다. cd는 셸의 현재 디렉터리를 변경하며, 정상적으로 실행되면 일반적으로 아무것도 출력하지 않습니다.
cd /home/labex/project/service-lab
복제본 두 개를 사용하는 Deployment 를 생성합니다. app: course-nginx Pod 레이블은 Service 가 이 레이블을 선택 기준으로 사용하므로 특히 중요합니다.
셸 구문 cat <<'EOF' > filename은 here-document입니다. 닫는 EOF가 나올 때까지의 모든 내용이 파일에 기록되며, >는 해당 파일을 생성하거나 기존 파일을 덮어씁니다. 첫 번째 EOF를 따옴표로 감싸면 YAML 내부에서 셸 변수가 실수로 확장되는 것을 막을 수 있습니다.
cat <<'EOF' > course-nginx-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: course-nginx
spec:
replicas: 2
selector:
matchLabels:
app: course-nginx
template:
metadata:
labels:
app: course-nginx
spec:
containers:
- name: nginx
image: nginx:1.27-alpine
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
protocol: TCP
EOF
적용한 뒤 두 Pod 가 준비될 때까지 기다립니다. -f는 apply가 읽을 파일을 지정하고, rollout status는 Deployment 컨트롤러가 작업을 완료할 때까지 기다립니다. --timeout=60s는 대기 시간을 제한합니다. 마지막 명령에서 -l은 레이블로 필터링하고, -o wide는 Pod IP 와 노드 열을 추가합니다.
kubectl apply -f course-nginx-deployment.yaml
kubectl rollout status deployment/course-nginx --timeout=60s
kubectl get pods -l app=course-nginx -o wide
이름이 지정된 http 포트는 각 컨테이너의 TCP 포트 80 을 문서화합니다. 두 행의 상태는 Running이어야 하며 서로 다른 Pod IP 가 표시되어야 합니다. 이 IP 들은 실제 주소이지만 클라이언트가 영구적으로 사용할 수 있는 주소는 아닙니다. 다음 단계에서는 이들 앞에 안정적인 Service 식별자를 추가합니다.
레이블과 Service 선택 연결
이 단계에서는 Service 와 Pod 를 연결하는 메타데이터를 확인합니다. Service 는 이름으로 Deployment 를 선택하지 않습니다. 대신 자체적으로 셀렉터와 일치하는 레이블을 가진 Pod 를 찾습니다.
Pod 레이블을 표시합니다. -l app=course-nginx는 레이블 셀렉터이고, --show-labels는 전체 레이블 집합을 마지막 열에 추가합니다.
kubectl get pods -l app=course-nginx --show-labels
각 Pod 에는 app=course-nginx와 함께 자동으로 생성된 pod-template-hash가 있습니다. Service 는 변경되지 않는 애플리케이션 레이블만 선택해야 합니다.
간결한 형식으로 이름, 레이블, IP 를 비교합니다. -o custom-columns는 선택한 객체 필드로 표를 만듭니다. : 앞의 각 제목 뒤에는 해당 열에 사용할 필드 경로가 오며, 백슬래시는 표시상 다음 줄까지 하나의 명령으로 이어 줍니다.
kubectl get pods -l app=course-nginx \
-o custom-columns='NAME:.metadata.name,LABEL:.metadata.labels.app,IP:.status.podIP,READY:.status.containerStatuses[0].ready'
자동으로 생성된 이름과 IP 는 개별 Pod 를 구분하고, 공통 레이블은 해당 Pod 의 역할을 나타냅니다. 이러한 간접 참조 덕분에 Pod 하나가 교체되어도 Service 는 계속 동작할 수 있습니다.
Deployment 의 셀렉터와 Pod 템플릿 레이블이 일치하는지 확인합니다. -o jsonpath='...'는 요청한 필드만 추출합니다. 중괄호 밖의 텍스트는 레이블이 되고, 중괄호 안의 필드 경로는 값을 반환하며, {"\n"}은 줄바꿈을 삽입합니다.
kubectl get deployment course-nginx \
-o jsonpath='Selector: {.spec.selector.matchLabels.app}{"\n"}Pod label: {.spec.template.metadata.labels.app}{"\n"}'
두 값 모두 course-nginx여야 합니다. 셀렉터가 일치하지 않으면 컨트롤러나 Service 가 의도한 Pod 와 연결되지 않습니다.
ClusterIP Service 생성
이 단계에서는 기본 Service 유형인 ClusterIP를 생성합니다. ClusterIP는 클러스터 내부의 워크로드에서 접근할 수 있는 가상 IP 와 DNS 이름을 제공합니다.
이 실습에서 앞서 사용한 here-document 패턴으로 매니페스트를 생성합니다.
cat <<'EOF' > course-nginx-service.yaml
apiVersion: v1
kind: Service
metadata:
name: course-nginx
spec:
type: ClusterIP
selector:
app: course-nginx
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
EOF
포트 매핑을 주의 깊게 살펴보세요.
port: 80은 클라이언트가 Service 에 접속할 때 사용하는 포트입니다.targetPort: http는 선택된 각 Pod 에 있는 이름이http인 컨테이너 포트를 가리킵니다.- 셀렉터는 백엔드 Pod 를 선택하는 기준이며, 네트워크 주소가 아닙니다.
검증하고 적용한 다음 Service 를 확인합니다. --dry-run=client는 아무것도 생성하지 않고 로컬에서 구문을 분석합니다. 이 옵션을 제거하면 실제로 적용되며, 이후 get service가 현재 클러스터의 객체를 읽습니다.
kubectl apply --dry-run=client -f course-nginx-service.yaml
kubectl apply -f course-nginx-service.yaml
kubectl get service course-nginx
CLUSTER-IP 값은 Kubernetes 가 할당합니다. 백엔드 Pod 가 변경되더라도 이 Service 가 존재하는 동안에는 해당 IP 가 안정적으로 유지됩니다.
Service 에서 EndpointSlice 까지 추적
이 단계에서는 Service 의 셀렉터가 실제 백엔드 주소로 어떻게 이어지는지 확인합니다. Kubernetes 는 이 주소를 EndpointSlice 객체에 기록합니다.
Service 를 자세히 확인합니다. describe는 이름으로 지정한 객체의 구성, 상태, 관련 엔드포인트 정보를 확장해 보여 주므로 간단한 get 표보다 상세한 점검에 유용합니다.
kubectl describe service course-nginx
Selector: app=course-nginx와 포트 80 에서 두 Pod IP 를 포함하는 Endpoints 항목을 찾습니다.
Service 이름 레이블로 선택된 EndpointSlice 를 나열합니다. 이 -l 셀렉터는 Kubernetes 가 자동으로 추가한 kubernetes.io/service-name=course-nginx 레이블을 사용합니다.
kubectl get endpointslices -l kubernetes.io/service-name=course-nginx
주소와 준비 상태를 확인합니다. 이 JSONPath 는 range를 사용해 모든 엔드포인트에 대해 중괄호 안의 템플릿을 반복합니다. 첫 번째 주소, 문자 그대로의 ready=, 준비 상태 값, 줄바꿈을 차례로 출력합니다.
kubectl get endpointslices -l kubernetes.io/service-name=course-nginx \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{" ready="}{.conditions.ready}{"\n"}{end}'
ready=true인 주소 두 개가 표시되어야 합니다. 이제 연결 경로가 명확해졌습니다.
Service selector -> matching Pod labels -> EndpointSlice addresses -> ready Pods
Service 는 존재하지만 엔드포인트가 없다면 먼저 Service 의 셀렉터가 Pod 레이블 및 준비 상태와 일치하는지 비교하세요.
클러스터 DNS 로 Service 에 접근
이 단계에서는 클러스터 내부의 클라이언트 역할을 수행합니다. Kubernetes DNS 를 사용하면 같은 네임스페이스의 Pod 가 가상 IP 를 기억하지 않고도 Service 이름인 course-nginx를 사용할 수 있습니다.
임시 BusyBox Pod 를 실행해 NGINX 페이지를 요청하고, 명령이 끝나면 클라이언트를 자동으로 삭제합니다. 옵션을 위에서부터 차례로 살펴보세요.
--image는 컨테이너 이미지를 선택하고,--image-pull-policy=IfNotPresent는 캐시된 이미지가 있으면 재사용합니다.--restart=Never는 컨트롤러가 관리하는 워크로드가 아니라 독립적인 Pod 를 생성합니다.--rm은 명령이 끝난 뒤 Pod 를 삭제하고,-i는 명령의 입력과 출력을 연결된 상태로 유지합니다.--구분자 뒤에서는 kubectl 옵션이 끝나며, 그 이후의 내용은 컨테이너 내부에서 실행할 명령입니다.wget -qO-는 URL 을 조용히 요청하고 응답 본문을 터미널에 출력합니다.
kubectl run service-client \
--image=busybox:1.36 \
--image-pull-policy=IfNotPresent \
--restart=Never \
--rm -i \
-- wget -qO- http://course-nginx
응답에는 NGINX 시작 페이지가 포함됩니다. 트래픽은 특정 Pod IP 로 직접 전달된 것이 아니라 Service 를 통해 전달되었습니다.
보다 간결하게 성공 여부를 확인합니다. >/dev/null은 HTML 본문을 버리고, &&는 요청 명령이 성공한 경우에만 메시지를 출력합니다.
kubectl run service-client-check \
--image=busybox:1.36 \
--image-pull-policy=IfNotPresent \
--restart=Never \
--rm -i \
-- wget -qO- http://course-nginx >/dev/null && echo "ClusterIP Service responded"
성공 메시지는 DNS 이름 확인과 HTTP 연결이 모두 정상임을 보여 줍니다. 클라이언트 Pod 는 임시로 생성되었지만 Service 와 두 백엔드 Pod 는 그대로 유지됩니다.
NodePort Service 추가
이 단계에서는 NodePort 유형을 사용해 동일한 Pod 를 대상으로 두 번째 Service 를 생성합니다. NodePort 는 기본 범위인 30000–32767에서 각 노드에 포트를 열고, 해당 트래픽을 Service 의 백엔드로 전달합니다.
Kubernetes 는 하나의 다중 문서 YAML 파일에서 여러 객체를 읽을 수 있습니다. 각 객체는 자체 apiVersion, kind, metadata, spec를 가지며, ---가 포함된 줄이 YAML 문서 사이의 구분자 역할을 합니다.
기존 ClusterIP Service 와 새 NodePort Service 를 함께 포함하는 재사용 가능한 파일을 생성합니다. 동일한 ClusterIP 정의를 반복해도 안전합니다. 같은 원하는 상태를 다시 적용하면 변경 없이 유지되기 때문입니다.
cat <<'EOF' > course-nginx-services.yaml
apiVersion: v1
kind: Service
metadata:
name: course-nginx
spec:
type: ClusterIP
selector:
app: course-nginx
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
---
apiVersion: v1
kind: Service
metadata:
name: course-nginx-nodeport
spec:
type: NodePort
selector:
app: course-nginx
ports:
- name: http
port: 80
targetPort: http
nodePort: 30080
protocol: TCP
EOF
클러스터를 변경하기 전에 두 YAML 문서를 함께 검증합니다. 출력에는 service/course-nginx와 service/course-nginx-nodeport가 모두 표시되고, 뒤에 (dry run)이 이어져야 합니다.
kubectl apply --dry-run=client -f course-nginx-services.yaml
적용한 뒤 두 Service 를 확인합니다. 첫 번째 명령은 하나의 파일에서 두 문서를 모두 읽습니다. 기존 ClusterIP Service 는 unchanged로 표시되고, NodePort Service 는 새로 생성됩니다. 두 번째 명령은 새로 생성된 Service 객체를 읽습니다.
kubectl apply -f course-nginx-services.yaml
kubectl get service course-nginx-nodeport
PORT(S) 열에는 80:30080/TCP가 표시됩니다. 여기서 포트 80 은 Service 포트이고 30080 은 노드에서 접근할 때 사용하는 포트입니다. 두 Service 는 동일한 Pod 를 선택하므로 동일한 백엔드 주소를 사용할 수 있습니다.
두 접근 경계 비교
이 단계에서는 NodePort 를 테스트하고 각 Service 유형이 언제 적합한지 정리합니다.
Minikube 노드 IP 를 가져옵니다. $(...)는 명령 치환 구문으로, 셸이 minikube ip를 실행한 뒤 그 출력을 변수 NODE_IP에 저장합니다. -p labex-v135는 준비된 프로필을 선택하고, echo는 저장된 값을 확인할 수 있게 합니다.
NODE_IP=$(minikube ip -p labex-v135)
echo "$NODE_IP"
노드에서 접근하는 포트를 통해 애플리케이션에 요청합니다. Service 를 수락한 직후 Kubernetes 가 새로운 노드 수준 네트워크 규칙을 설정하는 데 몇 초가 걸릴 수 있습니다. 재시도 옵션을 사용하면 첫 연결이 거부되더라도 짧은 수렴 시간을 기다리며 다시 시도할 수 있습니다.
curl -s --retry 5 --retry-connrefused --retry-delay 2 "http://${NODE_IP}:30080" | grep 'Welcome to nginx'
여기서 -s는 진행률 표시를 숨기고, --retry 5는 최대 다섯 번 재시도하며, --retry-connrefused는 초기 연결 거부를 재시도 가능한 오류로 처리합니다. --retry-delay 2는 시도 사이에 2 초를 기다립니다. 큰따옴표를 사용하면 URL 내부에서 ${NODE_IP}가 확장됩니다. 파이프 |는 반환된 HTML 을 grep으로 전달하고, grep은 일치하는 시작 페이지 행을 출력해 요청이 정상적으로 도달했음을 보여 줍니다.
일치하는 HTML 제목은 요청이 백엔드 Pod 에 도달했다는 증거입니다. 구축한 두 경로를 비교해 보세요.
in-cluster Pod -> course-nginx:80 -> ready backend Pod
VM/node client -> NODE_IP:30080 -> course-nginx-nodeport:80 -> ready backend Pod
두 Service 를 함께 확인합니다.
kubectl get services course-nginx course-nginx-nodeport
클러스터 내부에서 안정적으로 통신할 때는 ClusterIP를 사용합니다. ClusterIP는 기본 유형이며, 다른 노출 방식의 일반적인 기반이 됩니다. NodePort는 노드 수준의 진입점을 추가하므로 학습, 개발 또는 외부 로드 밸런서와의 연동에 유용합니다. 두 유형 모두 올바른 셀렉터와 준비된 EndpointSlice 에 의존합니다.
다음 과제에서는 이 개념을 직접 활용해 둘 이상의 웹 워크로드를 노출합니다.
요약
교체될 수 있는 Pod 앞에 안정적인 네트워크 식별자를 구축했습니다. Service 셀렉터를 Pod 레이블에 연결하고, 선택된 백엔드를 EndpointSlice 를 통해 추적했으며, 클러스터 DNS 로 ClusterIP Service 에 접근하고, 노드를 통한 NodePort 진입점도 추가했습니다.
핵심 개념은 Service 가 애플리케이션 자체가 아니며 Pod 를 포함하지도 않는다는 것입니다. Service 는 준비된 백엔드 중 셀렉터와 일치하는 집합을 지속적으로 나타냅니다. 연결에 문제가 발생하면 Service 포트, 셀렉터, Pod 레이블, EndpointSlice, 백엔드 준비 상태, 마지막으로 클라이언트가 접근하는 경계 순서로 연결 경로를 확인하세요.


