앞 글에서 StatefulSet, DaemonSet, Job 같은 워크로드 리소스를 정리했습니다.
Pod를 띄우는 방법은 어느 정도 감이 잡혔는데, 한 가지 빠진 게 있었습니다. 이 Pod들이 서로 어떻게 통신하고, 외부에서는 어떻게 접근하는지를 다루지 않았거든요. Deployment로 Pod 3개를 띄웠다고 합시다. 이 Pod들에게 요청을 보내려면 IP를 알아야 하는데, Pod는 생성될 때마다 IP가 바뀝니다. 롤링 업데이트라도 하면 기존 IP는 전부 사라지게 됩니다.
이 문제를 어떻게 해결하는지, 그리고 클러스터 외부에서는 어떻게 접근하는지를 이번 글에서 정리합니다.
Service — 변하지 않는 연결점
Pod IP는 왜 못 쓰는 걸까?
Pod에 IP가 할당되긴 합니다. kubectl get pod -o wide로 확인할 수 있죠. 문제는 이 IP가 일시적이라는 겁니다. Pod가 재시작되면 다른 IP를 받고, 롤링 업데이트를 하면 기존 Pod가 사라지면서 IP가 통째로 바뀝니다.
클라이언트 입장에서 "10.244.1.5로 보내면 되지"라고 하드코딩해두면, 배포 한 번 할 때마다 설정을 고쳐야 하기 때문에, 이렇게는 사용을 할 수 없습니다.
Service는 이걸 해결하는 추상화 계층입니다. 고정된 가상 IP(ClusterIP)와 DNS 이름을 제공하고, 셀렉터에 매칭되는 Pod들로 트래픽을 자동 분산합니다. Pod가 교체되더라도 Service의 주소는 변하지 않으니, 클라이언트는 Service 하나만 바라보면 됩니다.
동작 원리
Service를 만들면 Kubernetes는 셀렉터에 매칭되는 Pod들의 IP를 수집해서 EndpointSlice라는 오브젝트에 저장합니다. 각 노드의 kube-proxy가 이 EndpointSlice를 보고, ClusterIP로 들어온 패킷을 실제 Pod IP로 전달하는 규칙을 커널(nftables/iptables)에 설정합니다.

패킷을 kube-proxy 프로세스가 중계하는 건 아닙니다. kube-proxy는 규칙만 설정하고, 실제 전달은 커널이 합니다.
ClusterIP — 클러스터 내부 통신
가장 기본적인 Service 타입이고, type을 생략하면 기본값입니다. 클러스터 내부에서만 접근할 수 있는 가상 IP를 받습니다.
apiVersion: v1
kind: Service
metadata:
name: backend-svc
spec:
# type: ClusterIP ← 기본값이므로 생략 가능
selector:
app: backend # 이 레이블을 가진 Pod들로 트래픽 전달
ports:
- port: 80 # Service가 노출하는 포트
targetPort: 8080 # Pod가 실제로 수신하는 포트
protocol: TCP
클러스터 내 다른 Pod에서 backend-svc라는 이름으로 접근할 수 있습니다.
같은 Namespace면 이름만으로 되고, 다른 Namespace의 Service라면 backend-svc.other-namespace까지 적어야 합니다.
# Service 생성
kubectl apply -f backend-svc.yaml
# Service 목록 및 ClusterIP 확인
kubectl get svc
# EndpointSlice에 Pod IP가 등록됐는지 확인
kubectl get endpointslices -l kubernetes.io/service-name=backend-svc
⚠️ 주의: Service에 접근이 안 되는 가장 흔한 원인은 셀렉터와 Pod 레이블 불일치입니다. EndpointSlice에 Pod가 하나도 없다면 Deployment의 spec.template.metadata.labels와 Service의 spec.selector를 비교해보세요.
NodePort — 노드 IP로 외부 노출
ClusterIP 위에 모든 노드의 특정 포트를 열어 외부에서 접근할 수 있게 합니다.
apiVersion: v1
kind: Service
metadata:
name: web-nodeport
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 8080
nodePort: 30080 # 30000-32767 범위. 생략하면 자동 할당
외부에서 <아무-노드-IP>:30080으로 접근하면 됩니다. 구조는 ClusterIP 위에 한 겹이 더 쌓이는 겁니다.
kubectl get svc web-nodeport
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# web-nodeport NodePort 10.96.50.120 <none> 80:30080/TCP 5s
다만 프로덕션에서는 잘 안 씁니다. 모든 노드에서 같은 포트를 점유하니 포트 충돌 관리가 번거롭고, 노드 IP를 직접 노출하게 되니 보안 면에서도 아쉽습니다. 개발이나 테스트 환경에서 빠르게 외부 접근을 열고 싶을 때 정도가 적절합니다.
LoadBalancer — 프로덕션 외부 노출
클라우드 환경(AWS, GCP, Azure)에서 외부 로드밸런서를 자동으로 생성해줍니다. NodePort + ClusterIP를 포함하면서, 앞단에 클라우드 로드밸런서가 붙는 구조입니다.
apiVersion: v1
kind: Service
metadata:
name: web-lb
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080
# EXTERNAL-IP가 할당될 때까지 대기 (수십 초~수 분)
kubectl get svc web-lb -w
# NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
# web-lb LoadBalancer 10.96.80.200 203.0.113.50 80:31234/TCP 60s
💡 참고: 온프레미스 환경에서는 클라우드 LB가 없으므로 EXTERNAL-IP가 <pending> 상태로 남습니다. MetalLB 같은 베어메탈 로드밸런서 구현체를 별도 설치해야 IP가 할당됩니다.
Headless Service — Pod에 직접 접근
clusterIP: None으로 설정하면 가상 IP 없이 DNS가 Pod IP를 직접 반환합니다. StatefulSet처럼 특정 Pod에 이름으로 접근해야 하는 경우에 씁니다.
apiVersion: v1
kind: Service
metadata:
name: db-headless
spec:
clusterIP: None # Headless
selector:
app: database
ports:
- port: 5432
DNS를 조회하면 ClusterIP 대신 Pod IP 목록이 A 레코드로 반환됩니다.
이전 글에서 다룬 StatefulSet의 serviceName 필드가 바로 이 Headless Service를 가리키는 거였습니다. redis-0.redis-headless.default.svc.cluster.local 같은 DNS로 개별 Pod에 접근할 수 있었던 이유가 여기에 있었습니다.
Service 타입 정리
정리하면 이렇습니다. ClusterIP → NodePort → LoadBalancer가 계층적으로 쌓이는 구조입니다.
| 타입 | 접근 범위 | 용도 |
| ClusterIP | 클러스터 내부만 | 내부 서비스 간 통신 |
| NodePort | 클러스터 외부 (노드IP:포트) | 개발·테스트 환경 |
| LoadBalancer | 클러스터 외부 (외부 LB IP) | 프로덕션 외부 트래픽 |
| Headless | 클러스터 내부 (Pod IP 직접) | StatefulSet, Pod 직접 접근 |
CoreDNS — 이름으로 통신하는 원리
Service를 만들면 IP가 할당되는데, 실제로는 IP 대신 backend-svc라는 이름으로 접근합니다. 이게 되는 이유가 CoreDNS입니다.
CoreDNS는 kube-system Namespace에서 돌아가는 클러스터 내부 DNS 서버입니다. Pod 안에서 curl http://backend-svc를 치면, Pod의 /etc/resolv.conf에 설정된 CoreDNS로 DNS 쿼리가 가고, CoreDNS가 Service의 ClusterIP를 응답합니다.
DNS 이름의 전체 형식은 이렇습니다.
<service-name>.<namespace>.svc.cluster.local
같은 Namespace에서는 backend-svc만으로 충분하고, 다른 Namespace면 backend-svc.other-ns까지 써야 합니다.
공식 문서를 찾아보니 Pod의 /etc/resolv.conf에 search default.svc.cluster.local svc.cluster.local cluster.local이 들어 있어서, 짧은 이름을 치면 search domain을 붙여가며 자동으로 찾아줍니다.
# Pod의 DNS 설정 확인
kubectl exec -it <pod-name> -- cat /etc/resolv.conf
# nameserver 10.96.0.10
# search default.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5
ndots 설정이 중요한 이유
ndots:5는 "점이 5개 미만인 이름은 search domain을 먼저 붙여서 시도한다"는 뜻입니다.
backend-svc(점 0개)를 조회하면 backend-svc.default.svc.cluster.local부터 시도하니 문제 없지만, 외부 도메인인 api.example.com(점 2개)도 search domain을 먼저 붙여 api.example.com.default.svc.cluster.local부터 시도합니다.
불필요한 DNS 쿼리가 4번이나 더 나가는 거죠.
외부 API 호출이 잦은 앱에서는 FQDN 끝에 .을 붙이면(api.example.com.) search domain을 건너뛰고 바로 조회합니다.
DNS 디버깅
Service 이름으로 접근이 안 될 때 확인하는 순서입니다.
# 1. CoreDNS Pod가 돌고 있는지
kubectl get pods -n kube-system -l k8s-app=kube-dns
# 2. 테스트 Pod에서 DNS 조회
kubectl run -it --rm debug --image=busybox:1.36 --restart=Never -- \
nslookup backend-svc.default.svc.cluster.local
# 3. CoreDNS 로그 확인
kubectl logs -n kube-system -l k8s-app=kube-dns
nslookup이 NXDOMAIN을 반환하면 Service 이름이 틀렸거나 Namespace가 다른 경우가 대부분입니다. kubectl get svc -A로 전체 Service 목록을 확인해봅니다.
Ingress — HTTP 라우팅과 외부 진입점
LoadBalancer의 한계
Service의 LoadBalancer 타입으로 외부 트래픽을 받을 수 있긴 합니다.
그런데 서비스가 여러 개면 어떻게 될까요? API 서버, 프론트엔드, 관리 페이지 — 각각 LoadBalancer Service를 만들면 클라우드 로드밸런서가 3개 생깁니다. 서비스가 10개면 10개. 비용이 선형으로 늘어납니다.
거기에 "api.example.com은 API 서버로, www.example.com은 프론트엔드로" 같은 호스트 기반 라우팅이나 TLS 인증서 관리도 L4 로드밸런서 수준에서는 할 수 없습니다.
Ingress는 이 문제를 해결합니다. 하나의 진입점으로 여러 Service에 호스트명·경로 기반으로 트래픽을 분배하고, TLS 종료까지 중앙에서 처리합니다.
Ingress = 규칙 + 컨트롤러
여기서 놓치기 쉬운 부분인데, Ingress 리소스를 만든다고 바로 동작하는 게 아닙니다. Ingress는 라우팅 규칙일 뿐이고, 이 규칙을 실제로 해석해서 프록시(Nginx, Envoy 등)에 반영하는 Ingress Controller가 별도로 설치되어 있어야 합니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
spec:
ingressClassName: nginx
tls:
- hosts:
- example.com
secretName: example-tls # TLS 인증서가 저장된 Secret
rules:
- host: example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-svc
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: frontend-svc
port:
number: 80
example.com/api/* 요청은 api-svc로, 나머지는 frontend-svc로 라우팅됩니다.
TLS Secret을 참조하면 Controller가 HTTPS를 종료하고 백엔드에는 HTTP로 전달합니다.
# Ingress 확인
kubectl get ingress
kubectl describe ingress app-ingress
# Ingress Controller가 설치되어 있는지 확인
kubectl get ingressclass
⚠️ 주의: pathType은 필수 필드입니다. v1 API에서 생략하면 거부됩니다. Prefix(경로 접두사 매칭)와 Exact(정확히 일치) 중 선택합니다.
Ingress-NGINX 은퇴와 대안
여기서 중요한 변화가 하나 있습니다. Kubernetes SIG Network는 2026년 3월에 Ingress-NGINX를 공식 은퇴시켰습니다.
이후 릴리스나 보안 패치가 없습니다. 가장 널리 쓰이던 컨트롤러가 은퇴했으니 꽤 큰 변화입니다.
기존 배포는 계속 동작하지만 유지보수되지 않으므로, 대체할 수 있는 컨트롤러를 설명합니다.
| Controller | 특징 |
| Envoy Gateway | CNCF 프로젝트, Gateway API 네이티브 |
| Contour | Envoy 기반, Ingress + Gateway API 지원 |
| Traefik | 자동 인증서, Ingress + Gateway API 지원 |
그리고 Ingress 자체도 신규 기능 추가가 중단된 상태입니다. 후계자가 Gateway API입니다.
Gateway API — Ingress의 대체자
Gateway API가 나온 이유를 보면, Ingress의 구조적 한계가 있었습니다. 컨트롤러마다 어노테이션이 달라서 이식성이 없었고, 하나의 리소스에 인프라 설정과 라우팅 규칙이 섞여 있어 역할 분리가 안 됐습니다. NGINX에서 Traefik으로 바꾸면 어노테이션을 전부 고쳐야 했죠.
Gateway API는 이걸 3계층으로 나눴습니다.
| 리소스 | 관지 주체 | 역할 |
| GatewayClass | 인프라 제공자 | 어떤 Controller를 쓸지 정의 |
| Gateway | 클러스터 관리자 | 리스너 설정 — "어디서 트래픽을 받을까" |
| HTTPRoute | 앱 개발자 | 호스트·경로 → Service 매핑 — "어디로 보낼까" |
인프라팀이 Gateway까지만 관리하고, 개발팀은 HTTPRoute만 작성하면 되는 구조입니다. Ingress에서는 이런 분리가 어려웠습니다.
# Gateway — 클러스터 관리자가 생성
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: main-gateway
spec:
gatewayClassName: envoy
listeners:
- name: https
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: example-tls
allowedRoutes:
namespaces:
from: All
---
# HTTPRoute — 앱 개발자가 생성
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-route
spec:
parentRefs:
- name: main-gateway
hostnames:
- "api.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /v1
backendRefs:
- name: api-v1-svc
port: 80
- matches:
- path:
type: PathPrefix
value: /v2
backendRefs:
- name: api-v2-svc
port: 80
v1.36 기준으로 핵심 리소스(GatewayClass, Gateway, HTTPRoute)가 모두 GA(Stable)이니, 신규 프로젝트라면 Gateway API로 시작하는 게 맞습니다.
기존 Ingress 기반 클러스터는 당장 바꿀 필요는 없지만, Ingress-NGINX 은퇴 이후 보안 패치가 없으므로 계획적으로 전환을 준비해야 합니다.
💡 참고: SIG Network에서 Ingress2Gateway v1.0을 제공합니다. 기존 Ingress 리소스를 Gateway API 리소스로 자동 변환해주는 도구인데, 컨트롤러별 어노테이션은 수동 매핑이 필요합니다.
NetworkPolicy — Pod 간 트래픽 제어
기본 상태는 "모두 허용"
여기까지 Service와 Ingress로 통신 경로를 만드는 방법을 봤습니다.
그런데 보안 관점에서 한 가지 문제가 있습니다. Kubernetes의 기본 네트워크 모델에서는 모든 Pod가 모든 다른 Pod와 자유롭게 통신할 수 있습니다. Namespace가 달라도 마찬가지입니다.
프론트엔드 Pod가 데이터베이스 Pod에 직접 접근할 수 있고, 침해된 Pod가 클러스터 전체를 탐색할 수 있다는 뜻입니다.
프로덕션에서는 이게 심각한 문제가 됩니다.
NetworkPolicy는 Pod 수준에서 인바운드/아웃바운드 트래픽을 선언적으로 제어하는 리소스입니다. 방화벽 규칙처럼 "이 Pod는 특정 소스에서 오는 트래픽만 허용한다"를 정의합니다.
⚠️ 주의: NetworkPolicy는 CNI 플러그인이 지원해야 동작합니다. Calico, Cilium, Weave Net은 지원하지만 Flannel은 지원하지 않습니다. CNI가 지원하지 않으면 NetworkPolicy를 만들어도 실제 차단이 일어나지 않습니다.
Default Deny — 먼저 전부 닫기
프로덕션 Namespace에 가장 먼저 적용하는 정책입니다. 모든 인바운드 트래픽을 거부한 다음, 필요한 것만 하나씩 여는 화이트리스트 방식입니다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: prod
spec:
podSelector: {} # 빈 셀렉터 = Namespace 내 모든 Pod
policyTypes:
- Ingress
# ingress 규칙 없음 = 모든 인바운드 거부
podSelector: {}가 빈 셀렉터인데, 이러면 해당 Namespace의 모든 Pod에 적용됩니다.
특정 트래픽만 허용
Default Deny를 건 다음, 필요한 통신만 열어줍니다. database Pod에 api Pod에서 오는 5432 포트만 허용하는 예시입니다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-allow-api-only
namespace: prod
spec:
podSelector:
matchLabels:
app: database # 정책 적용 대상
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api # api Pod만 허용
ports:
- protocol: TCP
port: 5432
이 정책이 적용되면 app=database Pod는 app=api Pod의 5432/TCP만 수신하고, 나머지는 전부 거부됩니다.
Egress Deny와 DNS 함정
아웃바운드도 거부할 수 있는데, 여기에 흔한 함정이 있습니다.
⚠️ 주의: Egress를 전면 거부하면 DNS 조회도 차단됩니다. Pod가 Service 이름으로 통신하려면 CoreDNS에 DNS 쿼리를 보내야 하는데, 이것까지 막혀버리는 거죠. Egress deny를 걸 때는 반드시 DNS(포트 53) 허용 규칙을 함께 추가해야 합니다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: prod
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {} # 모든 Namespace (kube-system의 CoreDNS 포함)
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
AND와 OR 구분
다른 Namespace에서 오는 트래픽을 허용할 때, namespaceSelector와 podSelector를 어떻게 배치하느냐에 따라 의미가 완전히 달라집니다. 이 부분이 직관적이지 않아서 꽤 헤맸습니다.
# AND: monitoring Namespace의 prometheus Pod만 허용
ingress:
- from:
- namespaceSelector:
matchLabels:
purpose: monitoring
podSelector: # 같은 from 항목 = AND
matchLabels:
app: prometheus
# OR: monitoring Namespace의 모든 Pod 또는 아무 곳의 prometheus Pod
ingress:
- from:
- namespaceSelector: # 별도 from 항목 = OR
matchLabels:
purpose: monitoring
- podSelector:
matchLabels:
app: prometheus
같은 from 항목 안에 나란히 쓰면 AND(둘 다 만족), 별도 항목으로 분리하면 OR(하나만 만족해도 허용)입니다. YAML 들여쓰기 하나 차이로 의미가 바뀌니 주의가 필요합니다.
적용 권장 순서
1. Default Deny Ingress + Egress (Namespace 전체)
2. DNS Egress 허용 (UDP/TCP 53)
3. 필요한 Pod 간 통신만 개별 허용
4. 외부 접근이 필요한 Pod만 Egress 추가 허용
# NetworkPolicy 목록
kubectl get networkpolicy -n prod
# 연결 테스트 (차단됐는지 확인)
kubectl run -n prod test-pod --image=curlimages/curl --rm -it -- \
curl -s --connect-timeout 3 http://database:5432
# 타임아웃이면 NetworkPolicy에 의해 차단된 것
정리
Pod 간 통신은 Service가 고정 IP와 DNS를 제공하고, CoreDNS가 이름을 IP로 해석해줍니다.
외부 HTTP 트래픽은 Ingress(또는 Gateway API)가 호스트·경로 기반으로 라우팅하고, NetworkPolicy가 Pod 간 트래픽을 화이트리스트 방식으로 제어합니다.
다음 글에서는 ConfigMap, Secret, Volume 등 설정과 스토리지를 다루겠습니다.
'DevOps > kubernetes' 카테고리의 다른 글
| 6. ConfigMap과 Secret - 설정을 이미지에서 분리 (0) | 2026.06.13 |
|---|---|
| 4. StatefulSet 부터 CronJob 까지 (0) | 2026.05.30 |
| 3. Pod에서 Deployment 까지 (0) | 2026.05.16 |
| 2. Kubernetes 클러스터 아키텍처 (0) | 2026.05.09 |
| 1. 쿠버네티스란 무엇일까? (0) | 2026.04.25 |