OKE(Oracle Kubernetes Engine) + NGINX Gateway Fabric. https://api.tinyclover.com 기준 네트워크 흐름.

NGF가 reverse proxy이므로 Mac mini와 동일하게 두 hop으로 나뉩니다. 각 hop이 L7~L1까지 별개 스택을 탑니다.

[Phase 1] Client ─L7─ DNS / TLS
                ─L4─ OCI Public LB → NodePort
                ─L3─ 워커 노드 kube-proxy iptables (NodePort → NGF Pod IP)
                ─L2─ veth peer (oci<id>) 통한 Pod 네임스페이스 진입
                ─L1─ NGF Pod TCP socket
                                ↓ NGF nginx가 TLS 종료, 새 TCP 연결 시작
[Phase 2] NGF Pod ─L7─ HTTPRoute 매칭 → upstream(backend Pod IP)
                  ─L4─ Pod 간 TCP
                  ─L3─ Pod 네임스페이스 라우팅 (default via 169.254.1.1)
                  ─L2─ ARP proxy (veth peer가 모든 MAC 응답)
                  ─L1─ backend Pod TCP socket

OCI VCN-Native Pod Networking은 flannel과 다른 구조입니다:

  • Pod IP가 노드와 같은 VCN 서브넷 (예: 10.0.10.0/24)에서 직접 할당
  • cni0 같은 bridge 없음. Pod 별 veth peer가 호스트에 oci<hash> 이름으로 1:1 등록
  • Pod의 default gateway는 169.254.1.1 link-local (실제로는 veth peer의 ARP proxy)
  • VXLAN 캡슐화 없음 (overlay 아님)

Phase 1: Client → NGF Pod

0. 준비

kubectl config use-context oke

L7. DNS / TLS

dig api.tinyclover.com +short
# <OCI_LB_VIP>  ← OCI Public LB VIP
 
curl -sv --max-time 5 -o /dev/null https://api.tinyclover.com/ 2>&1 | \
  grep -E "Connected to|TLSv|subject:"
# subject: CN=api.tinyclover.com  (Let's Encrypt; TLS는 NGF Pod에서 종료)

L4. OCI Load Balancer

api-gateway-nginx Service가 type: LoadBalancer이며, OCI Cloud Controller Manager가 이를 보고 OCI Load Balancer(클래식, L4/L7 지원)를 자동 생성합니다. OCI에는 별도의 Network Load Balancer (NLB) 제품도 있지만 이 환경은 LB를 사용 중입니다 (shape: flexible annotation 기준). LB는 항상 SNAT 모드로 동작하므로 client source IP는 노드까지 보존되지 않고 LB의 백엔드 IP(예: 10.0.20.x)로 보입니다. 보존이 필요하면 NLB로 교체하거나 X-Forwarded-For 헤더에 의존해야 합니다.

kubectl -n tiny-clover get svc api-gateway-nginx
# LoadBalancer  10.96.x.x  <OCI_LB_VIP>  443:<NODEPORT>/TCP
 
kubectl -n tiny-clover get svc api-gateway-nginx -o yaml | \
  grep -E "type:|nodePort:|externalTrafficPolicy:|healthCheckNodePort:"
# type: LoadBalancer
# nodePort: <NODEPORT>
# externalTrafficPolicy: Local
# healthCheckNodePort: <HC_NODEPORT>

핵심 포인트:

  • NodePort : OCI LB가 워커 노드들의 포트로 트래픽 분산
  • externalTrafficPolicy: Local: 노드 → Pod 단계에서 SNAT 차단 (LB → 노드 단계의 SNAT는 별개로 LB 자체 설정). 노드에 backend Pod이 있을 때만 healthy
  • healthCheckNodePort <HC_NODEPORT>: kube-proxy 자동 노출. OCI LB가 이 포트로 헬스체크해 “이 노드에 Pod 있는가” 판단

api-gateway-nginx는 DaemonSet이라 모든 워커에 1개씩 떠 있고 두 노드 다 healthy → LB가 round-robin 분산:

kubectl -n tiny-clover get pods -l app.kubernetes.io/name=api-gateway-nginx -o wide
# api-gateway-nginx-xxxxx  10.0.10.cc  10.0.10.aa
# api-gateway-nginx-yyyyy  10.0.10.dd  10.0.10.bb

노드 진입

이 아래는 노드 안에서 봐야 합니다. kubectl debug node로 privileged 디버그 Pod을 띄우고 호스트 네임스페이스로 chroot:

NODE=10.0.10.aa
kubectl debug node/$NODE -it --image=nicolaka/netshoot --profile=sysadmin -- chroot /host

이후 명령어는 모두 노드 내부에서 실행한 결과 기준입니다.

L3. 노드 kube-proxy iptables (NodePort → Pod IP)

OKE는 kube-proxy iptables 모드 (config의 mode: "" = 기본값). NodePort 진입 → KUBE-EXT → KUBE-SVL → KUBE-SEP → DNAT 체인을 따라갑니다.

먼저 NodePort 가 어느 KUBE-EXT 체인으로 jump하는지 확인 후, 그 hash로 체인 전체를 따라갑니다.

# 1. NodePort 진입점 → KUBE-EXT 해시 확인
iptables-save -t nat | grep <NODEPORT>
# -A KUBE-NODEPORTS ... --dport <NODEPORT> -j KUBE-EXT-XXXXXXXXXXXXXXXX
 
# 2. 그 hash로 EXT/SVC/SVL/SEP 체인 일괄 추출
HASH=XXXXXXXXXXXXXXXX
iptables-save -t nat | grep -E "KUBE-(EXT|SVC|SVL|SEP)-$HASH|KUBE-SEP-" | grep -E "$HASH|api-gateway-nginx"
# -A KUBE-EXT-XXXX... -j KUBE-SVL-XXXX...
# -A KUBE-SVC-XXXX... -> 10.0.10.x:443  (probability)
# -A KUBE-SVL-XXXX... -> 10.0.10.x:443  ← Local 정책 전용 (로컬 노드 endpoint만)
# -A KUBE-SEP-... -j DNAT --to-destination 10.0.10.x:443

externalTrafficPolicy=Local이라 노드마다 KUBE-SEP가 다릅니다: 10.0.10.aa 노드는 자신의 NGF Pod(10.0.10.cc)만, 10.0.10.bb 노드는 자신의 Pod(10.0.10.dd)만 가리킵니다.

KUBE-SVL-* 체인이 externalTrafficPolicy=Local의 핵심. 일반 KUBE-SVC는 클러스터 전체로 분산하지만 SVL은 로컬 노드 endpoint만 jump 후보로 둡니다.

소켓 관점:

ss -tlnp | grep -E ":<NODEPORT>|:<HC_NODEPORT>"
# LISTEN 0.0.0.0:<HC_NODEPORT>  users:(("kube-proxy",...))  ← healthCheckNodePort만 listen
# <NODEPORT>는 listen 안 함. iptables PREROUTING이 가로채 DNAT

OKE는 iptables 모드라 ipvsadm -Ln은 빈 출력입니다.

L2. 노드 인터페이스 + Pod veth

ip addr show | grep -E "^[0-9]+:|inet " | head
# enp0s6: inet 10.0.10.aa/24  ← 노드 primary VNIC
# enp1s0: inet 10.0.10.ff/32  ← OCI VCN-Native CNI secondary VNIC
# oci<hash>@if3, oci<hash>@if3, ...  ← Pod별 veth peer

oci<hash> 인터페이스가 Pod의 eth0과 짝을 이룬 veth peer입니다. cni0 같은 bridge는 없습니다. Pod IP는 노드 라우팅 테이블/VCN 라우팅에 직접 등록되어 destination 인터페이스로 점프합니다.

L1. NodePort 진입 패킷 캡처

# 외부에서 curl https://api.tinyclover.com/ 친 상태에서
tcpdump -i any -nn -c 5 'tcp port <NODEPORT> and tcp[tcpflags] & tcp-syn != 0'
# IP 10.0.20.x.<port> > 10.0.10.aa.<NODEPORT>: Flags [S]  ← LB로부터 SYN 도착
# (src=10.0.20.x는 OCI LB 백엔드 IP. OCI LB는 항상 SNAT 모드라 외부 client IP가
#  보존되지 않음. client IP가 필요하면 NLB(Network Load Balancer)로 교체하거나
#  X-Forwarded-For 헤더에서 추출해야 함. NGINX 레벨이 아닌 OCI 인프라 차원.)
 
tcpdump -i any -nn -c 5 'host 10.0.10.cc and tcp port 443'
# IP 10.0.20.x.<port> > 10.0.10.cc.443: Flags [S]  ← DNAT 후 NGF Pod로

exit 두 번 → debug pod 종료.

여기서 NGF Pod의 nginx가 TLS handshake를 끝내고 HTTP 요청을 해석합니다. Phase 1 종료.


Phase 2: NGF Pod → backend Pod

NGF nginx가 새 TCP 연결을 backend Pod로 엽니다 (예: service-api 10.0.10.ee:5050). TLS 없음 (Pod 간 평문 HTTP).

L7. HTTPRoute 매칭 + upstream

NGF는 Service ClusterIP를 사용하지 않고 EndpointSlice를 watch해서 Pod IP를 nginx upstream에 직접 박습니다. kube-proxy의 ClusterIP DNAT 단계를 건너뜁니다.

NGF_POD=$(kubectl -n tiny-clover get pods \
  -l app.kubernetes.io/name=api-gateway-nginx \
  -o jsonpath='{.items[0].metadata.name}')
 
kubectl -n tiny-clover get httproute service-api \
  -o jsonpath='{.spec.rules[0]}' | python3 -m json.tool
# matches: [{path: {type: PathPrefix, value: /service-api/v1}}]
# backendRefs: [{name: service-api, port: 5050}]
 
kubectl -n tiny-clover exec $NGF_POD -c nginx -- \
  grep -A 3 "server_name api.tinyclover.com" /etc/nginx/conf.d/http.conf | head
 
kubectl -n tiny-clover exec $NGF_POD -c nginx -- \
  grep -B 1 -A 6 "upstream tiny-clover_service-api_5050" /etc/nginx/conf.d/http.conf
# upstream tiny-clover_service-api_5050 {
#     random two least_conn;
#     server 10.0.10.ee:5050;  ← Pod IP 직접
# }

EndpointSlice와 일치 확인:

kubectl -n tiny-clover get endpointslices \
  -l kubernetes.io/service-name=service-api \
  -o jsonpath='{.items[0].endpoints[0].addresses[0]}'
# 10.0.10.ee

L4. Pod 간 TCP

BACKEND_IP=$(kubectl -n tiny-clover get pod \
  -l app.kubernetes.io/name=service-api \
  -o jsonpath='{.items[0].status.podIP}')
 
kubectl -n tiny-clover exec $NGF_POD -c nginx -- \
  curl -sv --max-time 3 http://$BACKEND_IP:5050/ 2>&1 | head -10
# Connected to 10.0.10.ee port 5050
# HTTP/1.1 ... (TLS 없음, 평문)

L3. NGF Pod 네임스페이스 라우팅

kubectl -n tiny-clover exec $NGF_POD -c nginx -- ip route
# default via 169.254.1.1 dev eth0
# 169.254.1.1 dev eth0 scope link

VCN-Native CNI 특징: Pod 안에서 보면 default gateway가 169.254.1.1 하나뿐입니다 (link-local). 모든 외부 패킷이 이 한 주소로 향합니다.

kubectl -n tiny-clover exec $NGF_POD -c nginx -- ip addr show eth0
# 3: eth0@if4: ...
#     inet 10.0.10.cc/24 brd 10.0.10.255 scope global eth0
  • eth0@if4: Pod의 eth0이 호스트(노드)의 ifindex 4번 인터페이스와 veth peer로 짝지어져 있다는 뜻. 호스트에서 ip link show하면 ifindex 4가 oci<hash> 형태로 보이고, Pod의 eth0으로 보낸 패킷은 그대로 그 oci로 빠져나옵니다.
  • Pod IP 10.0.10.cc/24가 노드 IP 10.0.10.aa와 같은 VCN 서브넷 10.0.10.0/24에서 할당된 점이 OCI VCN-Native CNI의 핵심. flannel은 노드 대역(192.168.x.x)과 Pod 대역(10.42.x.x)을 따로 쓰지만, OCI는 같은 대역에서 IP를 직접 할당해 overlay 없이 VCN 라우팅으로 처리합니다.

L2. ARP proxy로 모든 MAC 응답

kubectl -n tiny-clover exec $NGF_POD -c nginx -- ip neigh show
# 10.0.10.aa    dev eth0 lladdr xx:xx:xx:xx:xx:xx ... STALE
# 169.254.1.1  dev eth0 lladdr xx:xx:xx:xx:xx:xx ... PERMANENT

핵심: 두 항목의 MAC이 동일합니다. veth peer 끝의 호스트가 proxy_arp를 켜고 어떤 IP를 묻든 자기 MAC으로 응답합니다 (AWS VPC CNI도 같은 패턴). Pod이 backend 10.0.10.ee로 보낸 ARP 요청도 같은 호스트 MAC으로 회신되며, 이후 패킷은 호스트 라우팅 테이블이 destination Pod의 oci<hash> veth로 점프시킵니다.

L1. 호스트 인터페이스에서 Phase 2 패킷 캡처

NGF Pod이 떠 있는 노드에 진입해 캡처합니다.

kubectl debug node/10.0.10.aa -it --image=nicolaka/netshoot --profile=sysadmin -- chroot /host
 
# 외부에서 curl https://api.tinyclover.com/service-api/... 친 상태에서
tcpdump -i any -nn -c 10 "host $BACKEND_IP and tcp port 5050"
# IP 10.0.10.cc.<port> > 10.0.10.ee.5050: Flags [S]   ← NGF → backend SYN
# IP 10.0.10.ee.5050 > 10.0.10.cc.<port>: Flags [S.]  ← backend → NGF SYN-ACK
# ... GET /service-api/v1/... HTTP/1.1, 200 OK ... (TLS 없음, 평문)

backend Pod이 다른 노드(10.0.10.bb)에 있으면 같은 캡처가 그 노드에서 잡힙니다. Pod IP는 VCN 라우팅이 ENI(VNIC) 단위로 매핑하므로 노드 간 통신도 캡슐화 없이 직접.


부록: 일반론

이 문서와 Mac mini 문서의 환경별 세부 사항은 다르지만, 클러스터 외부 트래픽이 Pod까지 도달하는 흐름은 어느 환경에서나 같은 골격을 따릅니다.

트래픽이 Pod에 닿는 5단계

  1. L7 외부: 도메인 → DNS → 외부 IP
  2. 외부 IP → 클러스터 진입: 환경마다 다름 (cloud LB, MetalLB, 호스트 pf RDR 등)
  3. L4-L3-L2-L1 1차: 노드 도착 → kube-proxy iptables가 Service IP/NodePort를 Pod IP로 DNAT → veth/bridge 통해 Gateway Pod에 도달
  4. L7 Gateway: TLS 처리 → HTTPRoute 매칭 → backend Pod IP로 새 TCP 연결 시작 (NGF는 ClusterIP 안 거치고 Pod IP 직접)
  5. L4-L3-L2-L1 2차: Gateway Pod 네임스페이스에서 다시 라우팅 → ARP → veth → backend Pod 도달

핵심:

  • Reverse proxy(Gateway/Ingress)는 L7→L1을 한 번 끝낸 뒤 새 TCP 연결로 다시 L7→L1을 시작합니다. 두 hop이 별개 스택입니다.
  • 각 hop의 L1~L4는 표준 Kubernetes/Linux 동작 (iptables, ARP, veth, conntrack)이라 환경 무관하게 같은 도구로 디버깅합니다.

환경별로 달라지는 세 가지 질문

같은 디버깅을 다른 cloud(EKS/GKE/AKS 등)에서 하려면 다음 세 가지만 먼저 답하면 됩니다:

  1. 외부 LB 종류와 SNAT 동작: 어떤 LB가 만들어지나? client source IP가 보존되나, SNAT되나? (예: OCI LB는 항상 SNAT, NLB는 옵션)
  2. CNI와 Pod IP 대역: Pod IP가 노드와 같은 대역에서 할당되나(VCN-Native, AWS VPC CNI) 아니면 별도 overlay 대역인가(flannel, Calico)? bridge 사용 여부?
  3. 노드 진입 방법: SSH 가능한가? kubectl debug node profile은 무엇인가? (Bottlerocket은 admin container 필요, COS는 일부 도구 부재)

TLS 처리 위치

“Gateway가 TLS를 종료한다”는 건 한 가지 패턴일 뿐, 설정에 따라 달라집니다:

  • Edge termination: Gateway가 TLS 종료, backend는 평문. 본 문서 두 환경 모두 이 방식 (argocd server.insecure: true, NGF listener tls.mode: Terminate).
  • Re-encryption / Backend TLS: Gateway가 TLS 종료 후 backend로 갈 때 새 TLS 연결. Gateway API에서 BackendTLSPolicy로 설정. 사용 사례: zero-trust, mTLS, 컴플라이언스.
  • TLS passthrough: Gateway가 TLS를 풀지 않고 SNI만 보고 forward. listener tls.mode: Passthrough. HTTP 헤더 못 봄 → path 기반 라우팅 불가.

backend Pod의 listen 포트와 TLS 여부는 위 모드와 짝을 이루어야 합니다 (예: edge termination이면 backend는 평문 포트, re-encryption이면 backend가 자체 cert로 HTTPS listen).

참고 자료