5.5 NetworkPolicy
지금까지의 계층이 만든 네트워크에는 경계가 없다. IP-per-Pod 원칙의 결과로 Kubernetes 클러스터의 모든 Pod는 기본적으로 다른 모든 Pod와 통신 가능하다. namespace는 이름과 권한의 경계일 뿐 네트워크 격리를 제공하지 않으므로, dev namespace의 Pod가 prod namespace의 데이터베이스에 접속해도 막는 장치가 없다. 침입자가 Pod 하나를 장악하면 거기서부터 클러스터 전체로 수평 이동이 가능해진다. NetworkPolicy는 이 전면 허용 상태에 방화벽 규칙에 해당하는 경계를 선언하는 리소스다.
정책의 구조와 동작 방식
NetworkPolicy는 namespace 스코프 리소스로, 세 요소로 구성된다. podSelector가 정책의 적용 대상을 고르고, policyTypes가 들어오는 방향(Ingress)과 나가는 방향(Egress) 중 무엇을 다루는지 정하고, ingress/egress 배열이 허용할 트래픽을 나열한다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-allow-web
namespace: prod
spec:
podSelector:
matchLabels:
app: db
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: web
ports:
- protocol: TCP
port: 5432이 정책은 prod namespace에서 app: db label의 Pod를 선택하고, app: web Pod에서 오는 TCP 5432만 허용한다. 동작 방식에서 중요한 것은 선택 여부에 따른 상태 전환이다. 어떤 정책에도 선택되지 않은 Pod는 전면 허용 상태 그대로다. 그러나 어느 정책이든 하나라도 그 Pod를 선택하는 순간, 해당 방향의 트래픽은 허용 목록 방식으로 뒤집힌다. 정책이 나열한 것 외에는 전부 차단된다.
정책에는 거부 규칙이 없고 허용만 있다. 같은 Pod를 여러 정책이 선택하면 허용 범위는 합집합이 되므로, 정책끼리 충돌한다는 개념 자체가 없고 순서도 무관하다.
from과 to에 들어가는 선택자는 세 종류다. podSelector는 같은 namespace의 Pod를, namespaceSelector는 특정 label을 가진 namespace 전체를, ipBlock은 CIDR 범위를 가리키며 except로 예외 대역을 지정한다. ipBlock은 주로 클러스터 외부 IP를 다루는 용도다. 클러스터를 드나드는 트래픽이 노드에서 SNAT를 거치는 환경에서는 Pod가 보는 출발지 IP가 원래 주소가 아닐 수 있어, 공식 문서도 클러스터 진입/이탈 트래픽에 대한 동작은 네트워크 플러그인에 따라 다를 수 있다고 안내한다.
선택자 결합의 함정: 리스트 항목이냐 한 항목이냐
from 배열에서 namespaceSelector와 podSelector를 함께 쓸 때, YAML의 - 하나가 의미를 가른다. 다음 두 정책 조각은 거의 같아 보이지만 허용 범위가 다르다.
# (A) 한 항목 안에 결합: AND
ingress:
- from:
- namespaceSelector:
matchLabels:
team: ops
podSelector:
matchLabels:
app: monitoring# (B) 두 항목으로 분리: OR
ingress:
- from:
- namespaceSelector:
matchLabels:
team: ops
- podSelector:
matchLabels:
app: monitoring(A)는 항목이 하나다. 두 선택자가 AND로 결합되어 “team: ops label이 붙은 namespace에 있으면서 app: monitoring label을 가진 Pod"만 허용한다. (B)는 podSelector 앞에 -가 붙어 항목이 둘이다. 배열 항목끼리는 OR이므로 “team: ops namespace의 모든 Pod” 그리고 “정책이 있는 namespace의 모든 app: monitoring Pod"가 전부 허용된다. 의도는 (A)였는데 (B)로 작성하면 허용 범위가 소리 없이 넓어진다. 반대 방향의 실수보다 발견이 어렵다. 트래픽이 막히면 바로 드러나지만, 더 열리는 쪽은 침투 테스트 전까지 아무 증상이 없기 때문이다.
default-deny 패턴
허용 목록 방식의 출발점은 전부 차단하는 기반 정책이다. 빈 podSelector: {}는 namespace의 모든 Pod를 선택하고, 허용 규칙이 하나도 없으므로 결과는 전면 차단이다.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: prod
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress이 정책을 namespace마다 두고, 필요한 통신을 개별 정책으로 하나씩 추가하는 방식이 표준 패턴이다. 정책은 합집합으로 동작하므로 기반 정책을 고칠 일 없이 허용 정책만 쌓으면 된다.
egress까지 차단할 때 빠뜨리기 쉬운 것이 DNS다. 이름 조회가 막히면 Service 접속이 IP를 알아내는 단계부터 실패하므로, kube-system의 CoreDNS로 가는 UDP/TCP 53을 허용하는 egress 정책이 사실상 항상 함께 필요하다.
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53kubernetes.io/metadata.name은 모든 namespace에 자동으로 붙는 label이라 namespace를 이름으로 지정할 때 그대로 사용한다.
CNI가 구현해야 동작한다
NetworkPolicy 리소스는 API 스펙일 뿐이고, 규칙을 패킷 차단으로 옮기는 집행자는 CNI 플러그인이다. Calico와 Cilium은 구현하지만 Flannel 단독 구성은 구현하지 않는다. 지원하지 않는 플러그인 위에서 NetworkPolicy를 만들면 리소스는 정상적으로 저장되고 kubectl get networkpolicy에도 나타나지만, 트래픽에는 아무 영향이 없다. 오류도 경고 이벤트도 없다.
kubectl exec로 접속을 시도해 실패하는 것까지 봐야 정책이 집행되고 있다고 판단할 수 있다. 조용히 무시되는 실패 모드 때문에, 만들어 둔 정책의 존재가 격리의 증거가 되지 못한다.관리형 Kubernetes에서는 NetworkPolicy 지원이 선택 항목인 경우가 있어, 클러스터 생성 시점에 정책 집행 옵션이나 지원 CNI를 선택했는지부터 확인해야 한다. 정책 위반 시도를 기록으로 남기는 감사 기능이나 L7 수준 규칙은 표준 NetworkPolicy 범위 밖이며, CNI별 확장(CiliumNetworkPolicy 등)의 영역이다. 클러스터 보안의 다른 축인 RBAC와 Pod 보안 표준은 Part IX. 보안에서 다룬다.