들어가며
쿠버네티스 클러스터의 기본 상태는 "모든 파드가 모든 파드와 통신 가능"입니다. 결제 서비스 파드가 사내 위키 파드에 접근할 수 있고, 침해당한 프런트엔드 파드가 데이터베이스에 직접 붙을 수 있습니다. 제로트러스트의 출발점은 이 기본값을 뒤집는 것, 즉 "명시적으로 허용된 통신만 가능"으로 만드는 것입니다.
표준 NetworkPolicy로도 어느 정도 가능하지만, 실무에서 금방 벽에 부딪힙니다. HTTP 경로 단위 제어가 안 되고, 외부 API를 도메인 이름으로 허용할 수 없고, 거부 로그를 볼 방법이 없습니다. Cilium은 CiliumNetworkPolicy(CNP)와 CiliumClusterwideNetworkPolicy(CCNP)로 이 간극을 메웁니다. 이 글에서는 정책 모델의 원리부터 L3/L4/L7/DNS 정책 YAML, 기본 거부 전환 전략, Hubble 기반 작성 워크플로, 흔한 함정까지 실전 순서대로 다룹니다.
표준 NetworkPolicy의 한계와 CNP의 확장
| 능력 | k8s NetworkPolicy | CiliumNetworkPolicy |
| --- | --- | --- |
| L3/L4 (파드 셀렉터, 포트) | 가능 | 가능 |
| L7 HTTP (메서드, 경로) | 불가 | 가능 |
| Kafka 토픽, gRPC 메서드 | 불가 | 가능 |
| DNS 이름 기반 egress | 불가 | 가능 (toFQDNs) |
| 명시적 거부(deny) 규칙 | 불가 (허용 목록만) | 가능 (ingressDeny/egressDeny) |
| 클러스터 전역 정책 | 불가 (네임스페이스 단위) | 가능 (CCNP) |
| 호스트(노드) 정책 | 불가 | 가능 (nodeSelector) |
| 거부 트래픽 가시성 | 구현체 의존 | Hubble로 즉시 확인 |
| 엔터티 개념 (world, host 등) | 불가 | 가능 |
중요한 전제: CNP도 표준 NetworkPolicy도 모두 identity 기반으로 같은 eBPF 데이터패스에서 평가됩니다. 두 종류를 혼용하면 "어느 한쪽이라도 허용하면 허용"으로 합산되므로, 팀 차원에서 어떤 리소스를 표준으로 쓸지 정해 두는 것이 운영 혼란을 줄입니다.
정책 모델 — identity와 방향
Cilium 정책 평가의 사고 모델은 다음과 같습니다.
ingress 정책 egress 정책
"누가 나에게 올 수 있나" "나는 어디로 갈 수 있나"
[src identity] ----> (엔드포인트) ----> [dst identity/CIDR/FQDN]
| |
| per-endpoint 정책 맵에서 O(1) 판정
| key: (identity, port, proto, 방향)
v
판정: ALLOW / DENY / (정책 없으면) 기본 허용*
* 단, 해당 방향에 정책이 하나라도 select되면
그 방향은 기본 거부로 전환됨 (중요!)
마지막 줄이 실무에서 가장 많이 사고를 내는 규칙입니다. 어떤 파드에 ingress 정책을 하나라도 적용하는 순간, 그 파드의 ingress는 화이트리스트 모드가 됩니다. egress는 egress 정책이 적용되기 전까지 여전히 전부 허용입니다. 방향별로 독립적으로 전환된다는 점을 기억해야 합니다.
L3/L4 정책 실전 YAML
네임스페이스 격리 (같은 네임스페이스만 허용)
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: ns-isolation
namespace: payments
spec:
endpointSelector: {} # 네임스페이스 내 모든 파드에 적용
ingress:
- fromEndpoints:
- {} # 같은 네임스페이스의 모든 파드 허용
빈 endpointSelector는 "이 네임스페이스의 모든 엔드포인트"를, ingress의 빈 fromEndpoints 항목은 "같은 네임스페이스의 모든 엔드포인트"를 뜻합니다. 이 정책 하나로 네임스페이스 외부에서 들어오는 트래픽은 모두 차단됩니다.
특정 서비스 간 허용 (프런트엔드 → 백엔드 8080)
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-frontend-to-backend
namespace: shop
spec:
endpointSelector:
matchLabels:
app: backend
ingress:
- fromEndpoints:
- matchLabels:
app: frontend
toPorts:
- ports:
- port: "8080"
protocol: TCP
다른 네임스페이스의 파드 허용
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-from-monitoring
namespace: shop
spec:
endpointSelector:
matchLabels:
app: backend
ingress:
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: monitoring
app: prometheus
toPorts:
- ports:
- port: "9090"
protocol: TCP
네임스페이스를 넘는 선택에는 `k8s:io.kubernetes.pod.namespace` 라벨을 사용합니다. 표준 NetworkPolicy의 namespaceSelector보다 표현이 직접적입니다.
명시적 거부 — egressDeny
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: deny-metadata-endpoint
namespace: shop
spec:
endpointSelector: {}
egressDeny:
- toCIDR:
- 169.254.169.254/32 # 클라우드 메타데이터 엔드포인트 차단
deny 규칙은 allow 규칙보다 항상 우선합니다. 클라우드 메타데이터 서버 차단처럼 "무슨 일이 있어도 막아야 하는" 항목에 적합합니다.
L7 정책 — HTTP, Kafka, gRPC
동작 원리: Envoy 연동
L7 정책이 붙은 트래픽의 경로가 어떻게 바뀌는지 이해해야 운영할 수 있습니다.
L4까지만: 클라이언트 파드 --eBPF--> 서버 파드 (커널 내 처리)
L7 정책: 클라이언트 파드 --eBPF--> [Envoy 프록시] --> 서버 파드
^
cilium-agent에 내장(또는 전용 파드)
eBPF가 해당 플로우만 프록시로 리다이렉트
HTTP 파싱 후 규칙 매칭, 위반 시 403
eBPF는 L7 규칙이 걸린 플로우만 선별적으로 Envoy에 넘기므로, L7 정책이 없는 트래픽은 여전히 커널 안에서만 처리됩니다. L7 검사 대상 트래픽에는 프록시 경유 비용(추가 레이턴시, 연결 종단)이 생긴다는 점을 수용해야 합니다.
HTTP 메서드/경로 제한
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: api-l7-allow
namespace: shop
spec:
endpointSelector:
matchLabels:
app: order-api
ingress:
- fromEndpoints:
- matchLabels:
app: frontend
toPorts:
- ports:
- port: "8080"
protocol: TCP
rules:
http:
- method: GET
path: /api/v1/orders.*
- method: POST
path: /api/v1/orders
- method: GET
path: /healthz
매칭되지 않는 요청(예: DELETE, 또는 /admin 경로)은 연결 자체는 성립하되 HTTP 403으로 거부됩니다. L4 차단과 달리 애플리케이션 로그 관점에서는 "접속은 되는데 403"으로 보이므로, 트러블슈팅 시 이 차이를 알고 있어야 합니다.
Kafka 토픽 제한
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: kafka-topic-policy
namespace: streaming
spec:
endpointSelector:
matchLabels:
app: kafka
ingress:
- fromEndpoints:
- matchLabels:
app: order-service
toPorts:
- ports:
- port: "9092"
protocol: TCP
rules:
kafka:
- role: produce
topic: orders
- fromEndpoints:
- matchLabels:
app: settlement-service
toPorts:
- ports:
- port: "9092"
protocol: TCP
rules:
kafka:
- role: consume
topic: orders
주문 서비스는 orders 토픽에 produce만, 정산 서비스는 consume만 가능하게 만드는 예입니다. 메시지 브로커를 공유하는 멀티테넌트 환경에서 토픽 단위 격리를 네트워크 계층에서 강제할 수 있습니다.
gRPC 메서드 제한
gRPC는 HTTP/2 위에서 "POST /패키지.서비스/메서드" 형태로 호출되므로 HTTP 규칙으로 표현합니다.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: grpc-method-policy
namespace: shop
spec:
endpointSelector:
matchLabels:
app: inventory-grpc
ingress:
- fromEndpoints:
- matchLabels:
app: order-api
toPorts:
- ports:
- port: "50051"
protocol: TCP
rules:
http:
- method: POST
path: /inventory.InventoryService/CheckStock
- method: POST
path: /inventory.InventoryService/ReserveStock
DNS 기반 egress 정책 — toFQDNs
외부 SaaS API를 IP로 허용하는 것은 유지보수 불가능합니다(IP가 수시로 바뀜). toFQDNs는 도메인 이름으로 egress를 허용합니다.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: allow-external-apis
namespace: payments
spec:
endpointSelector:
matchLabels:
app: pg-gateway
egress:
1) DNS 질의 자체를 허용하고, DNS 프록시로 관찰 대상으로 만든다
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
rules:
dns:
- matchPattern: "*"
2) 허용할 외부 도메인
- toFQDNs:
- matchName: api.stripe.com
- matchPattern: "*.tosspayments.com"
toPorts:
- ports:
- port: "443"
protocol: TCP
DNS 프록시의 동작
파드 --(DNS 질의)--> [Cilium DNS 프록시] --> CoreDNS/외부 DNS
|
| 응답의 A/AAAA 레코드를 가로채
| "이 파드는 api.stripe.com = 54.187.x.x 를 알게 됨"
v
ipcache/정책 맵에 해당 IP를 toFQDNs identity로 등록
|
파드 --(TCP 443 to 54.187.x.x)--> eBPF가 IP 기반으로 허용
핵심: toFQDNs는 "패킷의 SNI를 보는 것"이 아니라, **그 파드가 DNS로 풀어본 이름과 응답 IP의 짝**을 기억했다가 IP 기반으로 허용하는 방식입니다. 따라서 DNS 규칙(위 YAML의 1번 블록)이 함께 없으면 toFQDNs는 동작하지 않습니다. 이것이 가장 흔한 설정 실수입니다.
기본 거부 전환 전략 — 4단계 로드맵
운영 중인 클러스터에 기본 거부를 한 번에 켜면 장애가 납니다. 검증된 점진 적용 순서는 다음과 같습니다.
1단계: 관찰 2단계: 핵심 경로 허용 3단계: 감사 모드 거부 4단계: 강제
Hubble로 현행 관찰 결과를 정책으로 기본 거부 정책 배포 audit 해제,
트래픽 전수 관찰 → 작성, 거부 없이 적용 → + policy-audit-mode → 실제 차단
(2~4주) (서비스별 PR 리뷰) (위반은 로그만) (네임스페이스별 순차)
1. **관찰**: Hubble 메트릭과 플로우 로그로 네임스페이스별 실제 통신 매트릭스를 수집합니다. 배치 작업(cron)처럼 드물게 도는 트래픽을 놓치지 않도록 최소 한 달 주기를 권합니다.
2. **핵심 경로 허용 정책 작성**: 거부 정책 없이 allow 정책만 먼저 배포합니다. 이 단계에서는 아무것도 차단되지 않으므로 안전합니다.
3. **감사 모드**: 엔드포인트를 policy-audit-mode로 전환하면, 기본 거부 정책이 있어도 실제로 막지 않고 "막혔을 트래픽"을 verdict로 기록합니다.
에이전트 전역 감사 모드 (helm: policyAuditMode=true)
또는 특정 엔드포인트만
kubectl -n kube-system exec ds/cilium -- cilium endpoint config 1234 PolicyAuditMode=Enabled
감사 판정 관찰: 실제 차단됐을 트래픽 찾기
hubble observe --verdict AUDIT --namespace payments
4. **강제 전환**: audit에서 일정 기간(예: 2주) 위반이 없으면 네임스페이스 단위로 audit를 해제합니다. 전 클러스터 일괄 전환은 금물입니다.
기본 거부 자체는 다음과 같이 명시적으로 배포하는 것을 권합니다.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: default-deny
namespace: payments
spec:
endpointSelector: {}
ingress:
- fromEndpoints: [] # 아무것도 매칭 안 됨 = 명시적 기본 거부
egress:
- toEndpoints: []
정책 작성 워크플로 — Hubble에서 정책으로
실무에서 정책은 머리로 짜는 것이 아니라 관찰에서 도출합니다.
1) 대상 서비스가 실제로 주고받는 트래픽 관찰
hubble observe --namespace shop --pod shop/order-api --last 1000
2) 어떤 identity와 통신하는지 요약
hubble observe --namespace shop --pod shop/order-api \
--output json | jq -r '.flow.destination.labels | join(",")' | sort | uniq -c
3) 정책 적용 후 거부될 트래픽이 없는지 확인 (audit 모드에서)
hubble observe --verdict AUDIT --namespace shop
4) 강제 후 드롭 모니터링
hubble observe --verdict DROPPED --namespace shop --since 1h
초안 작성에는 네트워크 정책 에디터(editor.networkpolicy.io)가 유용합니다. Hubble 플로우를 업로드하면 관찰된 트래픽 기반으로 정책 초안을 시각적으로 생성해 줍니다. 단, 생성된 초안은 반드시 사람이 리뷰해야 합니다. 관찰 기간에 없었던 정상 트래픽(장애 조치 경로, 월 배치)은 초안에 빠져 있기 때문입니다.
host policy와 외부 엔터티
엔터티(entities) 개념
클러스터 외부 세계를 다루기 위한 예약 식별자들입니다.
| 엔터티 | 의미 |
| --- | --- |
| world | 클러스터 외부 전부 (인터넷 포함) |
| cluster | 클러스터 내 모든 엔드포인트 |
| host | 로컬 노드 자신 |
| remote-node | 다른 노드들 |
| kube-apiserver | API 서버 |
| health | Cilium 헬스체크 엔드포인트 |
클러스터 내부 통신 + API 서버만 허용하는 egress 예
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: cluster-only-egress
namespace: internal-tools
spec:
endpointSelector: {}
egress:
- toEntities:
- cluster
- kube-apiserver
호스트 정책 (CCNP + nodeSelector)
노드 자체의 트래픽도 정책 대상이 됩니다. SSH와 kubelet 포트만 허용하는 예입니다.
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: host-fw-control-plane
spec:
nodeSelector:
matchLabels:
node-role.kubernetes.io/control-plane: ""
ingress:
- fromEntities:
- cluster
- fromCIDR:
- 10.50.0.0/24 # 관리망
toPorts:
- ports:
- port: "22"
protocol: TCP
- port: "6443"
protocol: TCP
호스트 정책은 잘못 만들면 **노드 전체를 잠가버릴 수 있으므로**, 반드시 policy-audit-mode로 충분히 검증한 뒤 강제해야 합니다. 호스트 방화벽 기능(hostFirewall.enabled)도 helm에서 켜져 있어야 합니다.
정책 테스트와 검증 자동화
정책도 코드입니다. CI에 넣을 수 있는 검증 수단들:
1) 스키마/문법 검증 (CI 단계)
kubectl apply --dry-run=server -f policies/
2) 시뮬레이션: 특정 트래픽이 허용되는지 사전 판정
kubectl -n kube-system exec ds/cilium -- \
cilium policy trace --src-k8s-pod shop:frontend-abc --dst-k8s-pod shop:backend-xyz --dport 8080
3) 실제 클러스터 통합 테스트 (스테이징)
cilium connectivity test --test pod-to-pod,pod-to-world
4) 회귀 테스트: 배포 후 드롭 카운트 비교
hubble observe --verdict DROPPED --since 10m --output json | jq length
GitOps 환경이라면 정책 디렉터리에 대한 PR에 위 1, 2번을 파이프라인으로 강제하고, 머지 후 스테이징에서 3, 4번을 자동 실행하는 구성을 권합니다.
흔한 함정과 안티패턴
1. **DNS 규칙 없는 toFQDNs**: 앞서 본 대로 DNS 프록시 규칙이 없으면 toFQDNs는 영원히 매칭되지 않습니다. 증상은 "DNS는 되는데 연결이 거부됨" 또는 그 반대입니다.
2. **DNS TTL과 IP 변동**: toFQDNs는 DNS 응답에 기반하므로, 애플리케이션이 DNS 캐시를 오래 들고 있다가 만료된 IP로 접속하면 거부될 수 있습니다. CDN처럼 IP가 빠르게 도는 대상은 matchPattern을 넓게 잡고, 에이전트의 FQDN 관련 TTL 설정(tofqdns-idle-connection-grace-period 등)을 점검하세요.
3. **시스템 네임스페이스를 같이 잠그기**: kube-system, 모니터링, 인그레스 컨트롤러 네임스페이스에 기본 거부를 성급히 적용하면 클러스터 기능 자체가 죽습니다. 별도 트랙으로, 가장 마지막에, 가장 신중하게 진행해야 합니다.
4. **헬스체크/프로브 차단**: kubelet의 liveness/readiness 프로브는 노드(host identity)에서 옵니다. ingress 정책에서 host 엔터티를 잊으면 파드가 무한 재시작에 빠집니다. Cilium은 기본적으로 프로브 트래픽을 자동 허용하지만, 호스트 정책과 조합하면 깨질 수 있습니다.
5. **초기 연결만 끊기는 미스터리**: 정책 적용 직후 기존 연결은 conntrack에 남아 계속 동작하고, 새 연결만 차단되는(또는 그 반대) 비대칭이 관찰될 수 있습니다. "지금 동작한다"가 "정책이 허용한다"의 증거가 아닙니다.
6. **라벨 오타와 빈 셀렉터의 오해**: matchLabels 오타는 "아무것도 선택 안 됨"으로 조용히 실패합니다. 적용 후 반드시 `cilium endpoint list`로 정책이 enforce 상태인 엔드포인트 수를 확인하세요.
7. **L7 정책을 전 트래픽에 남발**: 모든 트래픽을 Envoy로 보내면 레이턴시와 CPU가 함께 오릅니다. L7 제어가 정말 필요한 경계(외부 노출 API, 민감 데이터 접근)에만 선별 적용하는 것이 정석입니다.
실전 시나리오 — PCI DSS 스타일 격리
카드 결제 데이터를 다루는 워크로드(CDE)를 클러스터 내에서 격리하는 패턴입니다. 규제 표현은 일반화한 예시이며, 실제 심사 요건은 QSA와 확인해야 합니다.
1) CDE 네임스페이스: 기본 거부 + 명시 허용만
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: cde-lockdown
namespace: cde-payments
spec:
endpointSelector: {}
ingress:
API 게이트웨이에서 오는 결제 요청만, L7 경로 제한까지
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: gateway
app: api-gateway
toPorts:
- ports:
- port: "8443"
protocol: TCP
rules:
http:
- method: POST
path: /v1/payments
- method: GET
path: /v1/payments/[0-9a-f-]+
egress:
DNS (프록시 경유 관찰)
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
rules:
dns:
- matchPattern: "*.internal.example.com"
- matchName: api.pgprovider.com
사내 원장 DB (전용 네임스페이스)
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: cde-db
app: ledger-db
toPorts:
- ports:
- port: "5432"
protocol: TCP
외부 PG사 API만
- toFQDNs:
- matchName: api.pgprovider.com
toPorts:
- ports:
- port: "443"
protocol: TCP
여기에 Hubble 플로우 로그를 장기 보관해 "격리가 실제로 유지되었음"을 감사 증적으로 제출하는 구성(다음 글에서 다룹니다)을 더하면, 네트워크 세그먼테이션 요건에 대한 기술적 답변이 완성됩니다.
도입 체크리스트
- [ ] 표준 NetworkPolicy와 CNP 중 팀 표준 리소스를 정했는가
- [ ] "정책이 하나라도 붙으면 그 방향은 기본 거부"를 팀 전체가 이해하는가
- [ ] Hubble 관찰 기간(최소 2~4주, 배치 주기 포함)을 확보했는가
- [ ] toFQDNs 정책마다 DNS 프록시 규칙이 짝으로 존재하는가
- [ ] policy-audit-mode 검증 단계가 배포 절차에 포함되어 있는가
- [ ] 시스템 네임스페이스는 별도 트랙으로 분리했는가
- [ ] 정책 PR에 dry-run과 policy trace가 CI로 강제되는가
- [ ] 강제 후 DROPPED verdict 알림이 모니터링에 연결되어 있는가
- [ ] 호스트 정책은 audit로 검증한 뒤에만 강제하는가
- [ ] 정책 변경 이력이 GitOps로 추적 가능한가
마치며
Cilium 정책의 힘은 표현력(L7, FQDN)에만 있는 것이 아니라, **관찰과 정책이 같은 데이터패스에서 나온다**는 데 있습니다. Hubble이 보여주는 플로우와 정책 엔진이 판정하는 플로우가 동일하므로, "관찰 → 정책화 → 감사 → 강제"의 루프가 추측 없이 닫힙니다. 제로트러스트는 한 번의 빅뱅이 아니라 이 루프를 네임스페이스 하나씩 돌리는 운동에 가깝습니다. 다음 글에서는 이 루프의 관찰 축을 담당하는 Hubble과, 멀티클러스터로 확장하는 ClusterMesh를 다룹니다.
참고 자료
- Cilium 네트워크 정책 공식 문서: https://docs.cilium.io/en/stable/security/policy/
- Cilium L7 정책(HTTP/Kafka) 문서: https://docs.cilium.io/en/stable/security/policy/language/
- Cilium DNS 기반 정책 문서: https://docs.cilium.io/en/stable/security/dns/
- Kubernetes NetworkPolicy 공식 문서: https://kubernetes.io/docs/concepts/services-networking/network-policies/
- 네트워크 정책 에디터: https://editor.networkpolicy.io/
- NIST SP 800-207 제로트러스트 아키텍처: https://csrc.nist.gov/pubs/sp/800/207/final
- PCI Security Standards Council: https://www.pcisecuritystandards.org/
- Envoy 프록시 공식 문서: https://www.envoyproxy.io/docs
- Apache Kafka 공식 문서: https://kafka.apache.org/documentation/
- gRPC 공식 문서: https://grpc.io/docs/
- Hubble GitHub 저장소: https://github.com/cilium/hubble
현재 단락 (1/371)
쿠버네티스 클러스터의 기본 상태는 "모든 파드가 모든 파드와 통신 가능"입니다. 결제 서비스 파드가 사내 위키 파드에 접근할 수 있고, 침해당한 프런트엔드 파드가 데이터베이스에 직...