Cilium DNS timeout의 원인은 NetworkPolicy가 아니었다
OCI와 온프레미스를 Tailscale로 연결한 RKE2 클러스터에서 Cilium DNS timeout을 추적했다. 정책 차단과 TPROXY 반환 경로, fragment DROP을 분리하고 1,355건으로 복구를 확인한 기록이다.
내가 운영하는 RKE2 클러스터는 OCI와 온프레미스 노드를 Tailscale로 연결한 단일 클러스터다. 2026년 9월 17일, 온프레미스 노드인 edge-b에서 cilium connectivity test가 실패했다. 같은 노드의 Pod로 보내는 HTTP 요청도, kube-dns 조회도 응답 없이 끝났다.
화면에 찍힌 증상만 보면 둘 다 같은 timeout이었다.
client 10.42.3.168 -> echo-same-node 10.42.3.101:8080 timeout
client 10.42.3.168 -> kube-dns 10.43.0.10:53 timeout
조사를 마치고 보니 두 요청은 전혀 다른 이유로 멈췄다. HTTP는 정책이 의도대로 막은 것이고, DNS는 정책을 통과한 뒤 로컬 프록시 앞에서 사라졌다. 그 와중에 Cilium monitor에는 First logical datagram fragment not found가 계속 찍혔다. 이 로그도 DNS와 직접 관련된 패킷이 아니었다.
비슷해 보이는 세 현상을 분리하는 데 시간이 가장 많이 들었다. 이 글에는 내가 어떤 근거로 각각을 떼어 냈고, 어디까지 수정했으며, 복구를 어떻게 확인했는지 적었다.
내가 확인한 환경
| 항목 | 구성 |
|---|---|
| Kubernetes | RKE2 v1.33.5+rke2r1 |
| CNI | Cilium v1.18.1 |
| 노드 | core-a, core-b, core-c, edge-a, edge-b |
| 배치 | OCI + 온프레미스 단일 클러스터 |
| kube-proxy | replacement 활성화 |
| 라우팅 | VXLAN tunnel, endpoint routes 활성화 |
| 노드 연결 | Tailscale |
당시 Cilium endpoint list에서 client와 echo-same-node는 모두 ready였다. 차이는 정책 상태였다. client에는 egress policy가 걸려 있었고, echo-same-node에는 정책이 없었다. 처음부터 이 차이를 보지 않았다면 같은 노드의 datapath가 망가졌다고 잘못 판단했을 것이다.
첫 번째 timeout은 정상적인 정책 차단이었다
Hubble과 cilium-dbg monitor에서 client endpoint의 흐름만 추렸다. client -> echo-same-node:8080에는 Policy denied가 찍혔다. connectivity test가 만든 client-egress-only-dns 정책은 이름 그대로 DNS만 내보내도록 허용하고 있었다.
따라서 이 timeout은 장애 증거가 아니었다. 정책이 해야 할 일을 한 결과였다.
DNS는 달랐다. CiliumNetworkPolicy에 UDP/TCP 53 허용 규칙이 있었고, 요청은 Cilium DNS 프록시 포트 36197로 TO_PROXY 처리됐다. 그런데 프록시에서는 L7 응답을 볼 수 없었다.
NetworkPolicy: DNS UDP/TCP 53 허용
Cilium verdict: TO_PROXY
redirect port: 36197
DNS result: timeout
이제 질문이 바뀌었다. “정책이 DNS를 막았나?”가 아니라 “정책을 통과한 패킷이 왜 프록시의 INPUT까지 오지 못했나?”를 봐야 했다. 무정책 Pod도 따로 띄워 직접 Pod, Service, DNS 경로를 검사했다. policy verdict와 datapath 상태를 섞지 않기 위한 비교군이었다.
mark가 붙은 조회만 EINVAL이 났다
Cilium은 transparent proxy의 upstream return 패킷을 로컬 프록시로 보내기 위해 0x200 mark를 사용한다. 이 클러스터에서는 Tailscale이 아래 값도 설정하고 있었다.
net.ipv4.conf.all.src_valid_mark = 1
src_valid_mark=1이면 Linux의 source validation은 패킷 mark를 경로 조회에 포함한다. 반면 edge-b의 endpoint veth는 accept_local=0이었다. 같은 tuple을 조회해도 mark가 없으면 성공하고, 0x200을 붙이면 EINVAL이 났다.
ip route get <endpoint-ip> from <dns-backend-ip> iif <lxc-interface>
ip route get <endpoint-ip> from <dns-backend-ip> iif <lxc-interface> mark 0x200
정책 단계에서는 DNS를 허용했다. 그 뒤 커널의 source validation이 marked packet을 local 경로로 판단했고, veth의 accept_local=0 때문에 프록시 앞에서 거부했다. Hubble의 TO_PROXY만 보고 프록시까지 도착했다고 생각하면 놓치기 쉬운 구간이었다.
veth 하나만 바꿔 보니 DNS가 바로 살아났다
가설을 확인하려고 문제 client가 사용하는 veth 하나에만 accept_local=1을 잠시 적용했다. 일정 시간이 지나면 0으로 돌아오게 해 두고 UDP와 TCP DNS를 다시 질의했다.
| 검사 | 변경 전 | A/B 변경 후 |
|---|---|---|
| UDP DNS | timeout | NOERROR, 약 21ms |
| TCP DNS | timeout | 성공, 약 35ms |
시험이 끝난 뒤 값이 0으로 복원된 것도 확인했다. 이 결과를 보고 5개 노드의 Cilium veth에만 설정을 영속화했다.
# /etc/sysctl.d/99-zzzz-cilium-tproxy-accept-local.conf
net.ipv4.conf.lxc*.accept_local = 1
전역 all.accept_local=0, all.src_valid_mark=1, tailscale0.accept_local=0은 그대로 뒀다. 새로 만들어진 veth에도 값이 적용되는지 확인했고, DNS L7 정책을 실제로 켠 후에는 30/30 조회가 성공했다. source IP를 위조한 패킷은 여전히 Cilium의 Invalid source ip로 차단됐다.
fragment DROP은 옆에서 벌어진 별도 장애였다
진단 도중 다음 로그가 쉴 새 없이 보였다.
drop (First logical datagram fragment not found)
처음에는 DNS timeout과 연결해 생각했다. IP를 대조하자 바로 맞지 않았다. DNS client는 10.42.3.168이었지만, DROP의 목적지는 edge-b의 Cilium router IP인 10.42.3.107이었다.
이 흐름만 따로 캡처했다. Cilium router IP 사이의 UDP 41641 트래픽이 VXLAN과 Tailscale 경로를 다시 타면서 112바이트씩 커지고 있었다. 중첩된 오버레이 안에서 패킷이 단편화됐고, 첫 fragment가 없는 조각이 들어왔다.
기존 방화벽 전체를 손대는 대신 전용 nftables table을 만들었다. Tailscale socket mark, UDP 41641의 출발지·목적지 포트, 현재 Cilium router IP 다섯 개를 모두 만족할 때만 차단하도록 범위를 묶었다.
table inet codex_ts_overlay_guard
Tailscale socket mark 0x80000/0xff0000
UDP source/destination port 41641
destination = 현재 5개 Cilium router IP 중 하나
edge-b에서 missing-first-fragment 카운터는 적용 전 10초 동안 208건 늘었고, 적용 후에는 증가하지 않았다. 다른 네 노드까지 같은 조건으로 10초간 살폈을 때도 증가분은 0이었다. tailscale0과 cilium_vxlan MTU 1280, 원격 Pod route MTU 1230은 바꾸지 않았다.
전체 suite에서 외부 L7 문제가 더 나왔다
veth 수정 뒤 Cilium 전체 suite를 돌리자 외부 HTTP L7 요청이 약 8.1초 뒤 503을 반환했다. 프록시를 거치지 않은 edge-b Pod에서는 외부 HTTP가 301, HTTPS가 200으로 정상 응답했다. 인터넷 연결 자체가 끊긴 상황은 아니었다.
503의 server 헤더는 envoy였다. Envoy의 upstream connect timeout 카운터도 한 번의 요청 동안 네 차례 늘었다. 외부 서버가 돌려준 503이 아니라, Envoy가 upstream 연결에 실패한 뒤 만든 응답이었다.
edge-b의 ens18에서 패킷을 잡아 보니 SYN은 밖으로 나갔고 SYN-ACK도 약 13ms 안에 돌아왔다. 이후 ACK가 없었다. veth에서 확인한 0x200 mark와 source validation의 충돌이 이번에는 물리 NIC에서 일어나고 있었다.
여기서도 한 인터페이스만 30초 동안 바꾸는 A/B 테스트를 했다. ens18.accept_local=1을 적용하자 같은 L7 요청이 HTTP 301, 약 45ms로 돌아왔다. 캡처에는 SYN, SYN-ACK, ACK와 HTTP 응답이 모두 남았다.
영속 설정은 외부 응답이 실제로 들어오는 NIC에만 적용했다. 설정을 켜기 전에 아래 nftables guard를 올려, 로컬 IPv4 주소를 출발지로 위조해 외부에서 들어오는 패킷은 계속 거부했다.
table inet codex_cilium_proxy_guard
prerouting hook priority raw
meta nfproto ipv4 iifname "<physical-nic>" fib saddr type local counter drop
OCI 노드는 enp0s6, edge-b는 ens18만 accept_local=1로 바꿨다. systemd 서비스는 guard를 먼저 설치한 다음 NIC 설정을 켜고, 중지할 때는 NIC를 먼저 0으로 돌린 뒤 guard를 지우도록 만들었다.
이 nft 규칙을 커널 source validation의 완전한 대체물로 보지는 않는다. 이번 경로에 필요했던 로컬 source 위조 방어를 별도로 남긴 것이다. 전역 값과 Tailscale 방화벽 모드는 바꾸지 않았다.
마지막에는 25개 방향을 새로 검사했다
마지막 수정까지 적용한 뒤, 2026년 9월 17일 16시 54분 KST에 5개 출발 노드와 5개 도착 노드의 모든 방향을 다시 검사했다. 서로 다른 노드 사이 20개 방향과 같은 노드 안의 5개 방향을 합친 수치다. 같은 노드에서도 client와 server는 서로 다른 Pod를 썼고, 도착 노드마다 전용 Service를 만들어 실제 응답 서버 이름도 맞춰 봤다.
| 검사 | 결과 |
|---|---|
| 전체 | 1,355건 중 PASS 1,280 / 정상 MTU 제한 75 / 실제 실패 0 |
| 직접 Pod + ClusterIP HTTP | 50/50 |
| TCP 64 KiB echo | 50/50 |
| UDP 7개 크기 × 3회 × 50경로 | 1,050/1,050 |
| ICMP | 25/25 |
| DNS ClusterIP 및 backend, UDP/TCP | 30/30 |
| DF/MTU | 전달 대상 75 성공 / MTU 초과 75건은 예상한 EMSGSIZE |
UDP payload는 64, 512, 1200, 1202, 1203, 1252, 4096바이트로 나눴다. MTU 한계를 넘도록 만든 75건은 실패가 아니라 예상한 거부였다. 이 구분 없이 총합만 보면 75건이 빠진 것처럼 보일 수 있어 별도 상태로 집계했다.
Cilium suite 11개 실패 기록도 그대로 남겼다
물리 NIC를 보완하기 전에 시작한 Cilium connectivity 전체 suite는 77개 실행 중 11개 실패, 46개 skip으로 끝났다. 실패 목록에는 나중에 고친 외부 L7/TLS/FQDN 경로뿐 아니라 실제 Tailscale 경로와 맞지 않은 패킷 캡처 조건, 누적 DROP 지표, 과거 로그와 기존 restart count 검사도 섞여 있었다.
그 뒤 만든 targeted 명령은 정규식 선택을 잘못해 시험을 하나도 실행하지 않고 123개를 전부 skip했다. 이 결과에는 통과했다고 부를 시험이 없다. 그래서 복구 근거에서도 제외했다.
내가 복구 판정에 사용한 것은 마지막 수정 후 새로 실행한 1,355건 매트릭스와 live 상태다. 전체 suite의 실패 숫자를 지우거나 targeted 명령의 skip을 성공으로 바꾸지 않았다.
사흘 뒤 상태와 아직 남은 확인 사항
2026년 9월 20일에 다시 접속했을 때 노드 다섯 개는 모두 Ready였다. Cilium 5/5, operator 2/2, Hubble Relay 1/1이 정상이고 cluster health도 5/5 reachable이었다. Cilium이 관리하는 Cluster Pod 64/64와 endpoint 74개가 모두 준비 상태였으며, CoreDNS의 두 backend도 UDP/TCP에서 active였다. First logical datagram fragment not found 누적 카운터는 5초 동안 늘지 않았다.
진단용 namespace 네 개와 테스트 Pod 30개는 이날 모두 삭제했다. Deployment, DaemonSet, Service도 namespace와 함께 사라졌는지 확인했다. sysctl, 두 nftables guard, systemd 설정은 운영 수정이므로 유지했다.
확인하지 못한 범위도 남아 있다. 실제 노드를 재부팅한 뒤 자동으로 재적용되는지까지 시험하지는 않았다. unit과 sysctl 파일, enabled 상태는 확인했지만 재부팅 성공으로 기록하지 않았다. 노드를 추가하거나 Cilium router IP, 물리 NIC가 바뀌면 두 guard의 대상도 갱신해야 한다.
이번 작업을 하면서 timeout이라는 한 단어가 원인을 설명해 주지는 않는다는 사실을 다시 배웠다. 정책이 만든 차단, 커널 source validation이 만든 DNS timeout, 오버레이 중첩이 만든 fragment DROP은 겉모습만 비슷했다. endpoint 상태와 policy verdict를 먼저 나누고, 패킷 tuple과 mark가 있는 FIB 조회를 맞춘 뒤, 인터페이스 하나씩 A/B 테스트한 것이 문제를 푸는 데 가장 도움이 됐다.
복구 여부도 같은 방식으로 판단했다. 전체 suite의 마지막 한 줄보다 내가 바꾼 경로를 다시 재현하고 25개 방향을 새로 검사한 결과가 더 직접적인 근거였다. 다음에 이 기록을 다시 보더라도 과장 없이 이어서 점검할 수 있도록, 통과한 범위와 남은 범위를 함께 적어 두었다.