쿠버네티스 클러스터를 처음 띄우면 모든 파드는 서로 자유롭게 통신할 수 있습니다. 프론트엔드 파드가 결제 DB에 직접 붙고, 이름 모를 배치 잡이 인증 서비스의 내부 API를 호출할 수도 있습니다. 이 “기본 전면 허용(default-allow)” 상태는 개발 초기에는 편하지만, 침해 사고가 나면 공격자가 클러스터 내부를 자유롭게 옆으로 이동(lateral movement)하는 통로가 됩니다.
네트워크 정책(NetworkPolicy)은 이 통로를 잠그는 파드 레벨 방화벽입니다. IP나 노드가 아니라 레이블(label) 기준으로 트래픽을 허용·차단하므로 파드가 재스케줄링돼 IP가 바뀌어도 규칙이 따라갑니다. 이 글에서는 동작 원리부터 기본 차단, 인/아웃 세분화, 네임스페이스 격리, DNS 예외, 실전 함정까지 단계별로 정리합니다.
네트워크 정책은 CNI 플러그인이 집행한다
먼저 오해 하나를 짚어야 합니다. NetworkPolicy를 kubectl apply 하는 것만으로는 아무 일도 안 일어날 수 있습니다. 네트워크 정책은 API 서버에 저장되는 선언일 뿐, 실제 패킷 필터링으로 집행하는 주체는 CNI 플러그인입니다. Calico·Cilium·Weave Net은 정책을 지원하지만 순수 flannel은 무시합니다. 정책을 적용했는데 트래픽이 통과한다면 십중팔구 CNI가 집행하지 않는 것입니다.
# CNI 플러그인 확인
kubectl get pods -n kube-system -o wide | grep -Ei 'calico|cilium|weave|flannel'
# 연결성 테스트용 임시 파드 (안에서: nc -zv 8080)
kubectl run netshoot --rm -it --image=nicolaka/netshoot -- /bin/bash
또 하나 중요한 특성은 정책이 누적 허용(additive) 화이트리스트라는 점입니다. 파드에 정책이 하나라도 걸리면 “명시적으로 허용된 트래픽만” 오갑니다. 여러 정책이 걸리면 허용 범위의 합집합이 되고, 명시적 “거부(deny)” 규칙은 없어 거부는 “허용하지 않음”으로 표현합니다.
가장 먼저 할 일: 기본 전면 차단
제로 트러스트의 출발점은 “일단 다 막고 필요한 것만 연다”입니다. 네임스페이스의 모든 파드에 대해 인/아웃을 차단하는 정책을 먼저 배포합니다.
# payments 네임스페이스의 모든 파드에 대해 인/아웃 전체 차단
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: payments
spec:
podSelector: {} # 빈 셀렉터 = 네임스페이스 내 모든 파드 선택
policyTypes:
- Ingress
- Egress
# ingress/egress 규칙을 하나도 안 적으면 = 전부 차단
podSelector: {}는 “이 네임스페이스의 모든 파드”를 뜻합니다. policyTypes에 방향을 넣되 규칙 블록을 비우면 해당 방향 트래픽이 전부 막힙니다. 이후 필요한 경로만 허용 정책으로 뚫습니다.
이 정책은 네임스페이스 단위입니다. 클러스터 전체에 한 방에 적용할 표준 방법이 없어, 네임스페이스마다 배포하거나 Kyverno·Calico GlobalNetworkPolicy 같은 클러스터 레벨 도구를 병행해야 합니다.
인그레스 세분화: 누가 나에게 접속할 수 있는가
기본 차단을 깔았으면 필요한 인그레스를 엽니다. 3-티어 구조에서 DB 파드는 오직 API 파드로부터만 연결을 받아야 합니다.
# app=postgres 파드는 app=api 파드의 5432 포트 접속만 허용
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-to-db
namespace: payments
spec:
podSelector:
matchLabels: { app: postgres } # 이 정책이 보호할 대상 파드
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels: { app: api } # 접속 허용 출발지 파드
ports:
- { protocol: TCP, port: 5432 }
여기서 from 아래의 podSelector는 같은 네임스페이스의 app: api 파드만 의미하며, 다른 네임스페이스의 동명 파드는 들어오지 못한다는 점이 자주 혼동을 부릅니다.
또한 from 리스트에서 셀렉터를 같은 원소로 묶느냐 별개 원소로 두느냐에 따라 AND와 OR가 갈립니다.
# AND: 같은 원소 안에 → 둘 다 만족하는 파드만
- from:
- namespaceSelector: { matchLabels: { tier: frontend } }
podSelector: { matchLabels: { app: web } }
# OR: 별개 원소(-)로 → 둘 중 하나만 만족해도 허용
- from:
- namespaceSelector: { matchLabels: { tier: frontend } }
- podSelector: { matchLabels: { app: web } }
이 들여쓰기 한 칸 차이가 보안 경계를 무너뜨립니다. OR로 잘못 쓰면 tier=frontend의 모든 파드가 뚫립니다.
이그레스 잠그기: 나는 어디로 나갈 수 있는가
인그레스만 막고 이그레스를 방치하는 것은 절반짜리 방어입니다. 침해된 파드가 외부로 데이터를 유출(exfiltration)하거나 내부 서비스를 스캔하는 것을 막으려면 이그레스 제어가 중요합니다.
# app=api 파드는 postgres(5432)와 DNS(53)로만 나갈 수 있다
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-egress
namespace: payments
spec:
podSelector:
matchLabels: { app: api }
policyTypes:
- Egress
egress:
- to:
- podSelector:
matchLabels: { app: postgres }
ports:
- { protocol: TCP, port: 5432 }
- to: # DNS 조회 허용 (아래 섹션 참고)
- namespaceSelector: {}
podSelector:
matchLabels: { k8s-app: kube-dns }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
이그레스를 잠글 때 가장 흔히 놓치는 것이 DNS입니다. 서비스 이름 조회 자체가 CoreDNS로 가는 이그레스 트래픽이라, 막히면 앱은 이름 해석 실패나 무한 타임아웃으로 죽습니다. 위 정책의 두 번째 to 블록이 이를 열어줍니다.
외부 IP 대역 제어: ipBlock
클러스터 밖의 특정 IP 대역(예: 온프레미스 결제 게이트웨이)으로만 나가도록 제한하려면 ipBlock을 씁니다. CIDR 기반이라 외부 목적지에 씁니다.
# 외부 대역 중 특정 서브넷만 제외하는 이그레스
egress:
- to:
- ipBlock:
cidr: 203.0.113.0/24 # 허용할 외부 대역
except: [ 203.0.113.128/25 ] # 그중 이 서브넷은 차단
ports: [ { protocol: TCP, port: 443 } ]
ipBlock의 함정은 SNAT입니다. 다수 CNI에서 파드 간 소스 IP가 노드 IP로 바뀌어, 내부 파드를 ipBlock으로 지정하면 매칭이 어긋납니다. 내부 통신은 셀렉터로, 외부 목적지만 ipBlock으로 다루세요.
네임스페이스 간 격리 패턴
멀티 팀 클러스터에서는 팀별 네임스페이스를 서로 침범하지 않게 격리하는 것이 흔한 요구입니다. 네임스페이스에 레이블을 붙여 namespaceSelector로 제어합니다.
# 네임스페이스에 식별 레이블 부여 (namespaceSelector 매칭 기준)
kubectl label namespace payments team=payments env=prod
kubectl label namespace monitoring purpose=observability
아래 정책은 관측성 네임스페이스의 메트릭 스크래핑만 예외로 허용합니다. 앱은 격리하되 모니터링은 접근해야 하는 현실적 상황입니다.
# purpose=observability 네임스페이스에서 metrics 포트(9100)만 허용
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-monitoring-scrape
namespace: payments
spec:
podSelector:
matchLabels: { app: api }
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels: { purpose: observability }
ports:
- { protocol: TCP, port: 9100 }
namespaceSelector는 네임스페이스 레이블을 기준으로 하지 이름 문자열을 매칭하지 않습니다. 레이블을 안 붙이면 아무것도 매칭하지 못해 트래픽이 막힙니다. 이름 기반 매칭이 필요하면 1.22+의 kubernetes.io/metadata.name 예약 레이블을 활용하세요.
정책 검증: 배포 전에 반드시 테스트한다
네트워크 정책은 “너무 열려(보안 구멍)”도 “너무 닫혀(서비스 장애)”도 위험합니다. 배포 전 실제 연결성을 검증하는 절차를 자동화하세요.
# 허용돼야 하는 경로: api → postgres (성공 기대)
kubectl exec -n payments deploy/api -- nc -zv -w 3 postgres 5432
# 차단돼야 하는 경로: 다른 네임스페이스 → postgres (실패=timed out 기대)
kubectl run probe --rm -it -n default --image=nicolaka/netshoot --
nc -zv -w 3 postgres.payments.svc.cluster.local 5432
# Cilium 사용 시: 정책 적용 여부를 파드별로 조회
kubectl exec -n kube-system ds/cilium -- cilium endpoint list | grep payments
이런 테스트를 CI에 넣어두면 새 정책이 기존 통신을 깨거나 경로를 여는 회귀를 조기에 잡습니다. “허용 경로는 성공, 차단 경로는 타임아웃”이라는 기대값을 명시하고 nc 종료 코드로 자동 판정하면 됩니다.
실전에서 자주 밟는 함정
네트워크 정책은 문법이 단순해 보이지만 미묘한 동작 때문에 운영에서 반복적으로 문제가 생깁니다.
- DNS 이그레스 누락: 이름 조회가 안 돼 앱이 멈춥니다. CoreDNS 53번 포트(UDP·TCP)를 항상 엽니다.
- AND/OR 혼동:
from배열의 원소 구분(-) 하나로 의미가 뒤집힙니다. 한 원소면 AND. - CNI 미지원: flannel이라 집행이 안 됩니다. 배포 전 지원 여부를 확인합니다.
- 헬스체크 차단: kubelet 프로브는 노드에서 오는데 정책이 막으면 파드가 재시작합니다.
- SNAT 소스 IP 왜곡: 내부 트래픽을
ipBlock으로 잡으려다 노드 IP로 어긋납니다. - 정책 없는 파드 = 전면 허용: 정책이 안 걸린 파드는 모든 트래픽을 받으므로 기본 차단이 없으면 격리가 안 됩니다.
특히 라이브니스 프로브 차단은 원인 파악이 까다롭습니다. 파드가 주기적으로 재시작한다면 프로브 트래픽이 정책에 막히지 않는지 의심하세요.
마무리
네트워크 정책의 핵심은 “레이블 기반 화이트리스트로 파드 간 트래픽을 최소 권한으로 좁힌다”입니다. 시작은 항상 네임스페이스별 기본 차단이고, 그다음 필요한 경로만 하나씩 엽니다. DNS 예외를 잊지 말고, AND/OR 문법을 정확히 이해하며, 배포 전 연결성 테스트로 검증하는 사이클을 지키면 대부분의 사고를 피할 수 있습니다.
다만 표준 네트워크 정책은 L3/L4(IP·포트) 수준에서만 동작합니다. HTTP 경로 제어, 파드 간 mTLS, FQDN 기반 이그레스 같은 L7 요구가 생기면 Cilium 확장 정책이나 서비스 메시로 넘어가야 합니다. 네트워크 정책은 견고한 1차 방어선이지만 그 자체로 완결된 보안 계층은 아닙니다.
자주 묻는 질문
Q. 네트워크 정책을 적용했는데 트래픽이 그대로 통과합니다. 왜 그럴까요?
A. 대부분 CNI가 정책을 집행하지 않는 경우입니다. 순수 flannel은 정책을 무시합니다. kubectl get pods -n kube-system으로 Calico·Cilium·Weave 같은 정책 지원 CNI가 있는지 확인하세요. CNI가 지원하더라도 대상 파드 레이블이 podSelector와 실제 일치하는지, 다른 허용 정책이 트래픽을 열어주고 있지 않은지(정책은 누적 허용) 함께 점검합니다.
Q. 이그레스를 막았더니 애플리케이션이 서비스 이름을 못 찾습니다.
A. DNS 조회가 이그레스 트래픽이기 때문입니다. CoreDNS(k8s-app: kube-dns)로 가는 53번 포트를 UDP·TCP 모두 허용하세요. 응답이 크면 조회가 TCP로 폴백하므로 TCP 53까지 열어야 합니다.
Q. 클러스터 전체에 한 번에 기본 차단 정책을 적용할 수 있나요?
A. 표준 NetworkPolicy는 네임스페이스 종속이라 전역 적용 기능이 없습니다. 네임스페이스마다 배포하거나, 새 네임스페이스 생성 시 정책을 자동 주입하도록 Kyverno·OPA Gatekeeper를 쓰세요. Calico GlobalNetworkPolicy나 Cilium CiliumClusterwideNetworkPolicy 같은 CNI 확장 리소스로 클러스터 레벨 규칙을 직접 정의할 수도 있습니다.