본문 바로가기
삵
Cloud Computing & MSA

NetworkPolicy로 파드 트래픽 기본 차단하기

냉냉장고를사다줘·2026년 10월 14일·조회 0

클러스터를 한참 운영하다 보면 어느 순간 깨닫는다. 같은 네임스페이스는 물론이고 네임스페이스를 넘어서도 파드끼리 TCP가 다 뚫려 있다는 사실을. 기본 상태의 쿠버네티스는 파드 간 통신을 전혀 막지 않는다. 프런트엔드 파드에서 결제 DB 파드로 바로 접속이 되고, 침해된 파드 하나가 클러스터 전체를 횡으로 훑는 것도 막을 게 없다. 이 글은 그 기본값을 뒤집어서, 아무것도 안 통하는 상태에서 시작해 필요한 통신만 하나씩 열어주는 방식을 다룬다.

결론부터 말하자면, 네임스페이스마다 default-deny NetworkPolicy를 먼저 깔아 ingress와 egress를 모두 차단하고, 그 위에 출발지와 목적지를 라벨로 지정한 허용 규칙을 얹는다. NetworkPolicy는 CNI가 실제로 집행하므로 Calico나 Cilium처럼 지원하는 플러그인이 깔려 있어야 하고, egress를 막으면 DNS(53번 포트)부터 따로 열어줘야 파드가 이름을 못 찾는다.

1. NetworkPolicy가 무엇이고 왜 기본값이 위험한가

NetworkPolicy는 파드 수준에서 L3/L4(IP, 포트) 트래픽을 제어하는 쿠버네티스 리소스다. TCP, UDP, SCTP를 다룬다. 중요한 전제가 하나 있다. 쿠버네티스 자체에는 이 정책을 집행하는 기능이 없다. NetworkPolicy 오브젝트를 아무리 만들어도 이를 지원하는 CNI 플러그인이 없으면 그냥 무시된다. Calico, Cilium, Antrea, kube-router 같은 플러그인이 집행을 담당한다.

파드의 격리는 두 방향으로 나뉘고 서로 독립적이다. 들어오는 트래픽이 ingress, 나가는 트래픽이 egress다. 어떤 NetworkPolicy도 특정 파드를 선택하지 않으면 그 파드는 비격리 상태라 모든 방향이 열려 있다. 반대로 하나라도 그 파드를 선택하고 policyTypes에 해당 방향이 들어 있으면, 그 방향은 격리되어 명시적으로 허용한 트래픽만 통과한다.

그래서 기본값이 위험하다. 예를 들어 웹 파드가 침해당하면 공격자는 같은 클러스터의 Redis, 내부 관리 API, 심지어 클라우드 메타데이터 엔드포인트까지 egress로 자유롭게 접근을 시도할 수 있다. 막아둔 게 없기 때문이다. 아래에서는 CNI 확인, default-deny 적용, 허용 규칙 설계, 격리 검증을 차례로 살펴본다.

2. CNI가 NetworkPolicy를 집행하는지 먼저 확인한다

제가 현장에서 제일 먼저 하는 일이 이거다. 정책을 아무리 정교하게 짜도 CNI가 집행을 안 하면 전부 헛수고라서다. 설치된 CNI를 먼저 본다.

$ kubectl get pods -n kube-system -o wide | grep -Ei 'calico|cilium'
calico-node-7xk9p        1/1     Running   0   6d
calico-kube-controllers-5f8c   1/1   Running   0   6d

Calico가 보이면 그걸로 된다. EKS는 기본 VPC CNI가 NetworkPolicy를 집행하지 않던 시절이 길었다. 지금은 VPC CNI에도 NetworkPolicy 집행 옵션이 있지만, L7이나 FQDN 같은 확장 기능이 필요하면 Calico나 Cilium을 따로 올리는 쪽을 권한다.

CNI가 없거나 집행을 안 하는 환경이라면 Calico를 operator로 올리는 방법이 가장 간단하다.

$ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml
$ kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/custom-resources.yaml
# 버전 번호는 저장소 상태에 따라 다를 수 있다. 공식 릴리스 노트에서 최신 태그를 확인할 것

집행 여부를 확실히 보려면 정책 없이 파드 두 개를 띄워 서로 붙여본 뒤, 뒤에서 만들 default-deny를 걸었을 때 끊기는지 비교하면 된다. 끊기면 집행이 되는 것이다.

3. 네임스페이스에 default-deny를 먼저 건다

격리는 네임스페이스 단위로 짜는 게 관리하기 편하다. 대상 네임스페이스의 모든 파드를 선택하려면 podSelector: {}로 빈 셀렉터를 쓴다. 빈 셀렉터는 네임스페이스 안 전체 파드를 의미한다.

ingress와 egress를 한 번에 막는 정책은 이렇게 쓴다.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress

ingress와 egress 목록 자체를 아예 적지 않았다. policyTypes에는 두 방향을 넣되 허용 규칙은 비어 있으니 두 방향 모두 전면 차단이 된다. 적용해 보면 바로 반응이 온다.

$ kubectl apply -f default-deny-all.yaml
networkpolicy.networking.k8s.io/default-deny-all created

$ kubectl -n shop exec deploy/web -- curl -sS --max-time 3 http://api:8080/health
curl: (28) Connection timed out after 3001 milliseconds
command terminated with exit code 28

여기서 처음 하면 거의 다 걸리는 함정이 하나 있다. egress를 막는 순간 DNS도 같이 막힌다는 점이다. 파드가 api 같은 서비스 이름을 IP로 못 바꾸니 curl이 타임아웃이 아니라 이름 해석 실패로 떨어지는 경우도 많다. 그래서 egress를 차단하면 DNS 허용 규칙을 반드시 짝으로 넣어야 한다.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: shop
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53

NetworkPolicy는 additive라 여러 정책이 쌓이고, 하나라도 허용하면 통과한다. 순서도 상관없다. 그러니 default-deny를 깐 상태에서 이 DNS 허용을 추가로 얹으면 53번만 열리고 나머지 egress는 여전히 막힌 상태가 유지된다.

4. 꼭 필요한 통신만 라벨로 열어준다

이제 web 파드가 api 파드의 8080으로만 접속하게 열어본다. 목적지(api) 쪽에서 ingress를 허용하고, 출발지(web) 쪽에서 egress를 허용해야 한다. 쿠버네티스 규칙상 연결이 성립하려면 출발지의 egress와 목적지의 ingress가 둘 다 통과시켜야 하기 때문이다. 한쪽만 열면 여전히 막힌다. 이걸 모르고 ingress만 열어두고 왜 안 되냐고 한참 들여다본 적이 있다.

목적지(api) ingress 허용:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: api-allow-from-web
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app: api
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: web
    ports:
    - protocol: TCP
      port: 8080

출발지(web) egress 허용:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: web-allow-to-api
  namespace: shop
spec:
  podSelector:
    matchLabels:
      app: web
  policyTypes:
  - Egress
  egress:
  - to:
    - podSelector:
        matchLabels:
          app: api
    ports:
    - protocol: TCP
      port: 8080

다른 네임스페이스에서 들어오는 트래픽을 받아야 한다면 namespaceSelector를 쓴다. 네임스페이스에는 kubernetes.io/metadata.name 라벨이 자동으로 붙으므로 이름으로 고를 수 있다. podSelector와 namespaceSelector를 같은 from 항목 안에서 함께 쓰면 교집합(그 네임스페이스의 그 라벨 파드)이 되고, 별도 리스트 항목으로 떼어 쓰면 합집합이 된다. 이 둘을 헷갈리면 의도보다 넓게 열린다.

클러스터 밖의 특정 대역으로 나가야 하면 ipBlock으로 CIDR을 지정한다. 다만 NetworkPolicy는 DNS 이름 기준으로는 막거나 열 수 없다. 도메인 단위로 egress를 통제하고 싶으면 Cilium의 toFQDNs 같은 확장 기능이 필요하다.

5. 격리가 실제로 먹는지 검증한다

정책을 올렸으면 반드시 양쪽을 다 찍어봐야 한다. 허용한 경로는 뚫리고, 막은 경로는 막혀야 한다. 둘 중 하나만 확인하면 반쪽짜리다.

허용한 web -> api:8080은 성공해야 한다.

$ kubectl -n shop exec deploy/web -- curl -sS --max-time 3 -o /dev/null -w "%{http_code}\n" http://api:8080/health
200

막은 경로, 예를 들어 web에서 db:5432로 가는 건 여전히 끊겨야 한다.

$ kubectl -n shop exec deploy/web -- curl -sS --max-time 3 http://db:5432/
curl: (28) Connection timed out after 3001 milliseconds
command terminated with exit code 28

어떤 정책이 특정 파드에 걸려 있는지 보려면 describe로 확인한다.

$ kubectl -n shop describe networkpolicy api-allow-from-web
Name:         api-allow-from-web
Namespace:    shop
PodSelector:  app=api
PolicyTypes:  Ingress
  Allowing ingress traffic:
    To Port: 8080/TCP
    From:
      PodSelector: app=web

Calico를 쓴다면 calicoctl로 집행 상태를, Cilium을 쓴다면 Hubble로 흐르는 트래픽과 드롭을 실시간으로 볼 수 있다. Cilium 환경에서는 hubble observe --verdict DROPPED로 어디서 막혔는지 바로 잡아낸다. 어느 쪽이든 핵심은 같다. 막힌 경로가 로그에 drop으로 찍히는지, 열어준 경로가 forward로 찍히는지 눈으로 확인하는 것이다.

6. Calico와 Cilium 중 무엇을 고르나

표준 NetworkPolicy만 집행하면 되고 L3/L4 통제로 충분하면 Calico로 간다. 가볍고 안정적이고, 쿠버네티스 NetworkPolicy를 그대로 집행한다. 네임스페이스를 넘는 클러스터 전역 정책이 필요하면 Calico의 GlobalNetworkPolicy로 확장할 수도 있다.

반면 도메인 기준 egress 통제(toFQDNs), HTTP 경로/메서드 같은 L7 정책, Hubble을 통한 가시성이 필요하면 Cilium을 고른다. 다만 Cilium의 default-deny 동작은 표준과 결이 조금 다르다. 표준 NetworkPolicy는 파드가 정책에 선택되는 순간 그 방향이 차단으로 바뀌는데, Cilium도 기본은 같지만 enableDefaultDeny.ingress: false처럼 명시적으로 그 전환을 끌 수 있다. 이 옵션을 모르고 켜두면 격리가 안 걸릴 수 있으니 Cilium을 쓸 때는 이 동작을 먼저 확인하는 걸 권한다.

운영 순서는 어느 CNI든 동일하다. 네임스페이스마다 default-deny를 먼저 깔고, DNS를 열고, 서비스 간 허용 규칙을 출발지와 목적지 양쪽에 짝으로 추가한 뒤, 허용과 차단을 둘 다 검증한다. 이 순서만 지키면 사고 날 일이 크게 준다.

자주 묻는 질문

NetworkPolicy를 만들었는데 아무 효과가 없다. 왜인가?

쿠버네티스 자체는 NetworkPolicy를 집행하지 않는다. Calico, Cilium, Antrea, kube-router처럼 NetworkPolicy를 지원하는 CNI 플러그인이 설치되어 있어야 한다. 지원 CNI가 없으면 정책 오브젝트는 생성되지만 트래픽에 전혀 영향을 주지 않는다.

default-deny를 걸었더니 파드가 서비스 이름을 못 찾는다.

egress를 차단하면 kube-dns로 가는 DNS 질의도 함께 막혀 이름 해석이 실패한다. kube-system 네임스페이스의 UDP/TCP 53번 포트로 나가는 egress를 허용하는 정책을 default-deny와 짝으로 추가해야 한다.

ingress만 열었는데 왜 연결이 안 되나?

연결이 성립하려면 출발지 파드의 egress 정책과 목적지 파드의 ingress 정책이 둘 다 통과시켜야 한다. 목적지에서 ingress를 열었어도 출발지에 egress 차단이 걸려 있으면 막힌다. 양쪽에 허용 규칙을 짝으로 넣어야 한다.

도메인 이름 기준으로 egress를 막을 수 있나?

표준 NetworkPolicy는 DNS 이름 기준 통제를 지원하지 않는다. IP CIDR(ipBlock)이나 라벨 셀렉터로만 지정할 수 있다. 도메인 단위 egress 통제가 필요하면 Cilium의 toFQDNs 같은 CNI 확장 기능을 써야 한다.

Calico와 Cilium 중 무엇을 써야 하나?

표준 L3/L4 통제로 충분하면 가볍고 안정적인 Calico를 권한다. 도메인 기준 egress(toFQDNs), HTTP 경로 같은 L7 정책, Hubble 가시성이 필요하면 Cilium을 고른다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.