Dev Notes
Web Vitals
Web Vitals
Dev Notes
Docs홈 k3s 클러스터 구축기 (8) — 사라지는 패킷을 쫓아서
Web Vitals
Web Vitals
GitHub
Infra59분

홈 k3s 클러스터 구축기 (8) — 사라지는 패킷을 쫓아서

Redis probe 타임아웃에서 공유기 펌웨어 버그까지, 네 번의 오진을 걷어내며 좁혀간 오버레이 패킷 유실 규명 과정

2026년 7월 30일
k3skubernetesnetworkingflannelvxlanwireguardcorednstcpdumptroubleshooting

환경: k3s v1.34.3+k3s1, Ubuntu Server 24.04 LTS, 노드 2대, flannel VXLAN → wireguard-native

들어가며

7편까지 클러스터는 GitOps로 배포되고, 분산 스토리지를 갖추고, 메트릭까지 수집하고 있었습니다. 구축 단계로만 보면 완성된 상태였지만, 한 가지가 계속 걸렸습니다. 4편에서 배포한 Redis의 replica Pod이 조용히, 그러나 꾸준히 죽고 있었습니다.

redis-replicas-0   1/1   Running   17 (4h12m ago)   6d

재시작 17회. 이유는 readiness와 liveness probe의 Timed out이었습니다. 이 편은 새로운 것을 얹는 구축기가 아니라, 이 한 줄에서 출발해 최종적으로 공유기 펌웨어의 비교 연산 버그에 도달하기까지의 규명 기록입니다.

결론만 보면 허무할 정도로 단순하지만, 이 글의 가치는 결론이 아니라 틀린 가설을 어떻게 걷어냈는가에 있습니다. 네 번 오진했고, 그중 하나는 정답 바로 옆까지 갔다가 표본이 작아서 후보에서 지워버린 경우였습니다. 그 과정을 생략하면 이 글은 읽을 이유가 없어집니다.

가설 1

probe 타임아웃이 짧다

상향 후 재시작은 멎었지만 실패 이벤트는 계속

가설 2

master CPU 쓰로틀

평균은 개선, max 7초 벽은 그대로

가설 3

DNS는 아니다

20회 표본으로 성급히 기각

가설 4

conntrack DNAT 경합

피해는 줄었지만 이론 자체가 반증됨

결론

공유기 UDP 필터

소스포트 하위 8비트가 0x43이면 100% 폐기

가설 1

probe 타임아웃이 짧다

상향 후 재시작은 멎었지만 실패 이벤트는 계속

가설 2

master CPU 쓰로틀

평균은 개선, max 7초 벽은 그대로

가설 3

DNS는 아니다

20회 표본으로 성급히 기각

가설 4

conntrack DNAT 경합

피해는 줄었지만 이론 자체가 반증됨

결론

공유기 UDP 필터

소스포트 하위 8비트가 0x43이면 100% 폐기

증상 — replica의 probe는 혼자 판단하지 않는다

먼저 무엇이 실패하고 있었는지 정확히 볼 필요가 있습니다. Bitnami Redis 차트의 replica probe는 단순한 로컬 헬스체크가 아닙니다. 스크립트 이름이 그대로 설명해 줍니다.

Bash
# replica의 readiness probe (차트 기본값 상태)
/health/ping_readiness_local_and_master.sh 1

local_and_master라는 이름대로 이 스크립트는 로컬 Redis와 master Redis 양쪽에 redis-cli로 PING을 보내고, 둘 다 성공해야 0을 반환합니다. replica의 readiness가 master 도달 가능성에 물려 있다는 뜻이며, 이 사실이 이후 진단 전체의 방향을 결정합니다.

인자로 넘어가는 숫자에도 함정이 있습니다. 차트는 values의 timeoutSeconds를 스크립트 인자로 그대로 넘기고, Kubernetes probe 자체의 timeoutSeconds는 거기에 1을 더해 렌더링합니다. 기본값 상태에서 스크립트가 손에 쥐는 예산은 1초, probe 전체로 쳐도 2초에 불과한데, 로컬과 master를 연달아 왕복하기에는 지나치게 빡빡한 값입니다.

master를 이름으로 찾는다는 것

replica는 REDIS_MASTER_HOST 환경변수로 redis-master-0.redis-headless.mirunamu.svc.cluster.local을 받습니다. 즉 probe 한 번에는 DNS 조회가 반드시 포함되며, 이 조회가 늦어지면 Redis가 완전히 건강해도 probe는 실패합니다. 이 지점이 나중에 결정적인 단서가 됩니다.

오진 1 — "재시작이 0회니까 해결됐다"

가장 먼저 손이 가는 조치는 예산을 늘리는 것입니다. readiness에 주어지는 시간을 2초에서 7초로, liveness를 6초에서 10초로 올리고, failureThreshold도 5에서 6으로 키웠습니다. 앞서 본 규칙대로 이 값이 그대로 스크립트 인자가 되므로, readiness는 7초, liveness는 10초의 왕복 예산을 갖게 됩니다.

YAML
replica:
  livenessProbe:
    timeoutSeconds: 10
    failureThreshold: 6
  readinessProbe:
    timeoutSeconds: 7
    failureThreshold: 6

결과는 즉각적이었습니다. 재시작 카운터가 멈췄고, kubectl get pods는 깨끗해졌습니다. 여기서 종료했다면 이 글은 없었을 것입니다.

문제는 이벤트를 확인했을 때 드러났습니다.

Bash
kubectl get events -n mirunamu --field-selector involvedObject.name=redis-replicas-0 \
  --sort-by=.lastTimestamp

probe 실패 이벤트는 여전히 15분에 한 번꼴로 발생하고 있었습니다. 재시작이 멎은 이유는 원인이 사라져서가 아니라, failureThreshold: 6이 요구하는 "30초 안에 6회 연속 실패"라는 조건에 산발적인 실패가 도달하지 못했기 때문입니다. 실패는 계속 일어나되 임계치를 넘지 못하고 있었을 뿐이며, 바뀐 것은 원인이 아니라 마진이었습니다.

여기서 얻을 교훈은 명확합니다. 재시작 횟수는 원인 지표가 아니라 증상 지표입니다. 임계치를 만지면 증상 지표는 얼마든지 0으로 만들 수 있고, 그 상태는 원인이 조금만 악화되어도 그대로 무너집니다. 실제로 이후 측정에서 드러난 지연은 새로 설정한 7초 예산의 턱밑까지 차 있었습니다.

오진 2 — master의 CPU 쓰로틀

replica의 probe가 master 응답에 물려 있다면, 의심의 다음 순서는 master입니다. cgroup v2의 cpu.stat은 CFS 쿼터에 걸려 강제로 멈춘 시간을 누적해 주므로, 쓰로틀 여부는 추측이 아니라 측정으로 확인할 수 있습니다.

Bash
# nr_periods / nr_throttled / throttled_usec
kubectl exec -n mirunamu redis-master-0 -- cat /sys/fs/cgroup/cpu.stat
kubectl exec -n mirunamu redis-replicas-0 -- cat /sys/fs/cgroup/cpu.stat
CPU limit쓰로틀된 CFS 주기 비율누적 쓰로틀 시간
master250m12.7%405초
replica500m4.3%28.8초

master는 전체 주기의 12.7%를 강제로 멈춰 있었고, 같은 시점 replica와 비교하면 누적 시간 기준 14배 차이였습니다. replica가 PING을 보낸 순간 master가 쿼터 소진으로 멈춰 있었다면 응답이 늦는 것은 당연합니다. 가설로서는 충분히 그럴듯했고, 실제로 개선 효과도 있었습니다. master의 limit을 500m로 올리자 replica에서 master로 보내는 PING의 평균이 179ms에서 112ms로 내려갔습니다.

그런데 최댓값이 요지부동이었습니다.

master PING (100회)   avg 112ms / max 7,009ms

7,009ms라는 숫자는 그 자체로 결정적인 정보를 담고 있습니다. 스크립트에 넘긴 타임아웃이 정확히 7초인데, 최댓값이 그 값에 소수점 자리까지 붙어 있다는 것은 "느려졌다"가 아니라 타임아웃까지 아무 응답도 오지 않았다는 뜻입니다. CPU 경합으로 인한 지연이라면 값이 그 사이 어딘가에 분포해야 하는데, 실제 분포는 100ms대와 7초 벽으로 완전히 갈라져 있었습니다. 점진적 지연과 완전한 정지는 다른 현상이며, CPU는 후자를 설명하지 못합니다.

쓰로틀 자체는 실재했으므로 limit 상향은 되돌리지 않고 유지했습니다. 다만 이것은 부수적인 개선이었을 뿐, 찾고 있던 원인은 아니었습니다.

오진 3 — 20회 표본으로 DNS를 기각하다

이 시리즈를 통틀어 가장 뼈아팠던 판단이 여기서 나옵니다.

probe 경로에 DNS가 끼어 있다는 것은 처음부터 알고 있었기에, 초반에 DNS 조회 시간을 재봤습니다. 20회를 돌렸고 결과는 8~35ms였습니다. 빠르고 안정적이었으므로 DNS는 후보에서 지웠습니다.

한참 뒤 다른 가설이 모두 무너진 다음, 같은 측정을 100회로 늘렸습니다.

Bash
# replica 컨테이너 안에서 계층별로 100회씩 (Debian 기반이라 date +%s%3N 사용 가능)
max=0; sum=0
for i in $(seq 1 100); do
  S=$(date +%s%3N)
  getent hosts redis-master-0.redis-headless.mirunamu.svc.cluster.local >/dev/null 2>&1
  E=$(date +%s%3N); D=$((E-S)); sum=$((sum+D)); [ $D -gt $max ] && max=$D
done
echo "dns avg=$((sum/100))ms max=${max}ms"
dns  max=10025ms

20회로는 절대 보이지 않던 값이 100회로 늘리자 나타났습니다. 발생 확률이 수 퍼센트인 현상은 20회 표본으로 존재 여부를 판단할 수 없습니다. 20회를 돌려서 안 나왔다는 것은 문제가 없다는 뜻이 아니라 이 표본으로는 말할 수 없다는 뜻이며, 그 둘을 혼동한 대가로 며칠을 다른 곳에서 보냈습니다. 간헐적 장애를 다룰 때 표본 크기를 먼저 정하지 않고 측정에 들어가면 이런 종류의 오진은 반드시 반복됩니다.

같은 방식으로 경로를 계층별로 쪼개 각각 100회씩 재자, 어느 구간이 문제인지가 한눈에 드러났습니다.

측정 대상avgmax
로컬 PING (같은 컨테이너)36ms187ms
master PING (크로스노드)112ms7,009ms
DNS 조회—10,025ms

10,025ms라는 값도 우연이 아닙니다. Pod의 기본 resolv.conf를 보면 이유를 알 수 있습니다.

Bash
kubectl exec -n mirunamu redis-replicas-0 -- cat /etc/resolv.conf
# nameserver 10.43.0.10
# search mirunamu.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5

timeout과 attempts가 지정되어 있지 않으므로 glibc의 기본값인 timeout=5, attempts=2가 적용됩니다. 질의 하나가 유실되면 5초를 기다렸다가 한 번 더 시도하고, 그것마저 유실되면 다시 5초를 기다린 뒤 실패합니다. 정확히 10초입니다. 즉 이 숫자는 지연의 크기가 아니라 패킷이 통째로 사라졌다는 사실을 말하고 있었습니다.

오진 4 — 교과서적인 답을 검증 없이 가져다 쓰다

Kubernetes에서 "간헐적 5초 DNS 지연"을 검색하면 거의 언제나 같은 답이 나옵니다. glibc가 A 레코드와 AAAA 레코드 질의를 같은 소켓으로 동시에 내보내고, 이 두 패킷이 conntrack의 DNAT 처리에서 경합해 한쪽이 유실된다는, 잘 알려진 커널 레이스 컨디션입니다. 증상의 형태가 정확히 일치했으므로 이것이 답이라고 판단하고 표준 완화책을 적용했습니다.

YAML
dnsConfig:
  options:
    - name: single-request-reopen   # A/AAAA를 각각 새 소켓으로 전송
    - name: timeout
      value: "2"
    - name: attempts
      value: "3"                    # 최악 6초 → probe 예산 7초 안쪽

효과는 있었습니다. DNS 최댓값이 10,025ms에서 6,091ms로 내려가 probe 임계치 안으로 들어왔고, probe 실패 이벤트가 멎었습니다. 지표만 보면 해결된 상태였습니다.

피해 억제와 원인 제거는 다르다

timeout 2, attempts 3이 한 일은 "패킷이 유실됐을 때 얼마나 오래 기다릴 것인가"를 10초에서 6초로 줄인 것뿐입니다. 패킷은 여전히 같은 비율로 사라지고 있었고, 사라지는 이유는 아무것도 밝혀지지 않았습니다.

이 가설은 이후에 명시적으로 반증됩니다. A 레코드만 단독으로 질의하도록 getent ahostsv4로 바꿔 측정했는데도 동일하게 정지가 발생했습니다. AAAA가 아예 없는 질의가 죽는다면 A/AAAA 경합 이론은 성립할 수 없습니다.

Bash
# A 단독 질의로도 동일하게 정지하는지 확인
for i in $(seq 1 100); do
  S=$(date +%s%3N)
  getent ahostsv4 kubernetes.default.svc.cluster.local >/dev/null 2>&1
  E=$(date +%s%3N); echo $((E-S))
done | sort -n | tail -3

증상의 형태가 유명한 사례와 닮았다는 것은 가설의 출발점이 될 수는 있어도 근거가 되지는 못합니다. 검색해서 나온 답을 적용했더니 지표가 좋아졌다면, 그것이 원인을 제거해서인지 임계치를 피해서인지 반드시 구분해야 합니다. 이 구분을 건너뛴 탓에 또 한 번 돌아왔습니다.

규명 1 — 변수를 하나만 남긴다

여기서부터가 실제 규명입니다. 지금까지의 측정은 전부 Redis Pod 안에서 이루어졌기 때문에, Redis라는 앱, Debian이라는 배포판, glibc라는 리졸버 구현이 모두 변수로 섞여 있었습니다. 이것들을 한 번에 지울 방법이 필요했습니다.

마침 클러스터에 완벽한 대조군이 있었습니다. 6편에서 배포한 Longhorn의 longhorn-ui가 양쪽 노드에 같은 이미지로 떠 있었습니다. 이미지가 같으므로 앱과 libc는 상수이고, 남는 변수는 노드 위치 하나뿐입니다.

Pod이 떠 있는 노드DNS 60회 결과
gwpark-ubuntu-1 (CoreDNS와 같은 노드)max 9ms, 실패 0
gwpark-ubuntu-2 (워커, 크로스노드)max 10,025ms, 3/60 정지

같은 이미지, 같은 질의, 같은 시간대인데 노드만 다릅니다. 앱도 libc도 리졸버 설정도 무죄이며, 문제는 노드를 건너는 경로에 있습니다. 이 시점에 조사 대상은 Redis에서 클러스터 네트워크로 완전히 바뀌었습니다.

규명 2 — 경로를 계층으로 쪼갠다

노드 간 경로라고 해도 그 안에는 여러 층이 겹쳐 있습니다. Pod에서 나간 패킷은 cni0 브리지를 지나 flannel의 VXLAN 터널로 캡슐화되고, 물리 NIC를 통해 스위치와 공유기를 거쳐 반대편 노드에서 역순으로 풀립니다. 어느 층에서 새는지 알아야 합니다.

DNS는 UDP라 유실을 재전송으로 가리지 않는 대신 응답 시간이 리졸버 타임아웃에 지배되므로, 순수한 경로 품질을 보려면 TCP 연결 시간을 재는 편이 낫습니다. 워커 노드의 Pod에서 세 종류의 목적지로 각각 80회씩 연결했습니다.

Bash
# 목적지만 바꿔 세 번 반복한다
#   A) 다른 노드의 파드 IP  (오버레이 경유)
#   B) 다른 노드의 호스트 IP (물리 네트워크만)
#   C) 같은 노드의 파드 IP  (노드 내부)
TARGET=10.42.0.10; PORT=53
max=0; over=0
for i in $(seq 1 80); do
  S=$(date +%s%3N)
  timeout 5 bash -c "exec 3<>/dev/tcp/$TARGET/$PORT" 2>/dev/null
  E=$(date +%s%3N); D=$((E-S))
  [ $D -gt $max ] && max=$D
  [ $D -gt 500 ] && over=$((over+1))
done
echo "max=${max}ms  >500ms=${over}/80"
경로max500ms 초과
VXLAN (다른 노드 파드 IP)1,079ms2/80
물리 (다른 노드 호스트 IP)93ms0/80
같은 노드66ms0/80

물리 네트워크는 완전히 깨끗한데 오버레이만 샙니다. 그리고 이 측정에는 또 하나의 중요한 정보가 있습니다. UDP뿐 아니라 TCP도 느려졌다는 사실입니다. 다만 TCP는 SYN 재전송이 있어 1초 남짓이면 회복되었습니다. 프로토콜 특유의 문제가 아니라 프로토콜과 무관하게 패킷이 유실되고 있고, UDP는 재전송이 없어 피해가 리졸버 타임아웃만큼 커질 뿐이었습니다.

규명 3 — 흔한 후보부터 지운다

오버레이 유실의 원인으로 흔히 지목되는 항목들이 있습니다. 하나씩 측정해 배제했습니다.

전부 무죄였습니다. 그런데 이 "전부 0"이라는 결과는 단순한 배제 이상의 의미를 가집니다. 양쪽 호스트의 커널 카운터 어디에도 흔적이 없다는 것은 패킷이 두 호스트의 바깥 어딘가에서 사라지고 있다는 강한 암시입니다. 이 관찰은 나중에 범인이 특정됐을 때 정확히 맞아떨어집니다.

이 단계에서 7편의 Prometheus가 뜻밖의 역할을 했습니다. Prometheus는 이미 30초마다 클러스터의 모든 타깃을 찌르고 있었고, 그 소요 시간을 3일치 시계열로 보존하고 있었습니다. 즉 아무 준비 없이도 3일치 손실 이력이 이미 쌓여 있었습니다.

PROMQL
scrape_duration_seconds{job="coredns"} > 1

이 쿼리 하나로 언제부터, 어느 타깃에서, 얼마나 자주 스파이크가 있었는지가 그래프로 나왔습니다. 간헐 장애를 쫓을 때 상시 수집 중인 모니터링은 사후 재현이 불가능한 데이터를 공짜로 제공합니다.

규명 4 — 여섯 지점에서 동시에 찍는다

커널 카운터가 아무 말도 하지 않는다면 남은 방법은 패킷을 직접 보는 것입니다. 어느 구간에서 사라지는지 특정하려면 한 지점이 아니라 경로 전체를 동시에 찍어야 합니다. 두 노드의 세 지점씩, 총 여섯 지점에서 동시에 캡처했습니다.

Bash
# 두 노드에서 각각 실행 (u1, u2 동시에)
tcpdump -i cni0      -nn -w /tmp/$(hostname)-cni0.pcap     'port 53' &
tcpdump -i flannel.1 -nn -w /tmp/$(hostname)-flannel1.pcap 'port 53' &
tcpdump -i enp1s0    -nn -w /tmp/$(hostname)-enp1s0.pcap   'udp port 8472' &

cni0는 Pod이 내보낸 원본, flannel.1은 캡슐화 직전, enp1s0은 전선으로 나가는 최종 형태입니다. 이 상태로 DNS 루프를 돌려 정지가 발생한 질의를 골라내고, 그 질의의 응답이 어디까지 살아 있었는지 여섯 개 캡처에서 추적했습니다.

결과는 명확했습니다. u1의 물리 NIC에서 송출된 것은 보이는데, u2의 물리 NIC에는 도착하지 않았습니다. 송신 측 커널은 자기 할 일을 다 했고 수신 측 커널은 아무것도 받지 못했습니다. 사망 구간은 두 노드 사이의 전선 구간입니다.

규명 5 — 0x43

죽은 흐름을 특정했으니 이제 공통점을 찾을 차례입니다. 살아남은 흐름과 죽은 흐름 사이에 무엇이 다른지 비교했을 때, VXLAN 외피의 소스포트에서 패턴이 나왔습니다.

정지가 발생한 흐름 7개의 외피 소스포트는 다음과 같았습니다.

46403 · 52291 · 56899 · 42819 · 49987 · 60483 · 40771

숫자 자체는 무작위로 보이지만, 256으로 나눈 나머지를 구하면 전부 67입니다. 16진수로 하위 바이트가 0x43인 포트만 골라 죽고 있었습니다.

우연일 가능성을 없애기 위해 캡처 전체를 이 기준으로 분류했습니다.

Bash
# 캡처에서 VXLAN 외피 소스포트를 추출해 하위 바이트로 분류
tcpdump -r u1-enp1s0.pcap -nn 'udp dst port 8472' 2>/dev/null \
  | sed -E 's/.* IP [0-9.]+\.([0-9]+) > .*/\1/' \
  | awk '{ k = ($1 % 256 == 67) ? "0x43" : "other"; c[k]++ } END { for (i in c) print i, c[i] }'
소스포트u1 → u2u2 → u1
하위 바이트 0x4314 송신 / 0 수신12 송신 / 0 수신
그 외2,323 송신 / 2,288 수신정상 범위

간헐이 아니었습니다. 하위 바이트가 0x43인 패킷은 한 개도 건너가지 못했습니다. 확률적으로 유실되는 것처럼 보였던 이유는 단지 VXLAN이 흐름마다 다른 소스포트를 고르기 때문이었고, 그 포트가 함정에 걸리면 해당 흐름은 100% 죽었던 것입니다.

규명 6 — 삼각측량으로 범인을 좁힌다

패턴은 찾았지만 아직 누가 죽이는지는 모릅니다. VXLAN 구현, 노드의 NIC, 드라이버, 중간 장비가 모두 후보입니다. 이 후보들을 갈라내려면 VXLAN을 방정식에서 빼야 합니다.

소스포트를 직접 지정한 순수 UDP 패킷을 쏘는 실험을 설계했습니다. 수신 측에서 대기시켜 두고 송신 측에서 발사하면, 도착 여부만으로 필터의 존재를 확인할 수 있습니다.

Bash
# 수신 측 노드에서 대기
tcpdump -i enp1s0 -nn 'udp port 9999'
Python
# 송신 측에서 소스포트를 지정해 발사 (포트 67 바인딩 때문에 root 필요)
python3 - <<'EOF'
import socket
TARGET = ("172.30.1.77", 9999)
for sp in [46403, 52291, 56899, 42819, 67,      # 하위 바이트 0x43 계열 + 진짜 DHCP 포트
           46404, 52292, 56900, 42820, 68, 51820]:  # 대조군
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(("", sp))
    s.sendto(b"probe-%d" % sp, TARGET)
    s.close()
EOF

결과가 필터의 성격을 정확히 규정해 주었습니다.

조건결과
하위 바이트 0x43 계열 4종 + 진짜 포트 67전멸
대조군 6종 + 포트 68전부 통과
같은 소스포트로 TCP 발사통과 — UDP만 검사
목적지 포트를 0x43으로 지정통과 — 소스포트만 검사
제3의 기기(작업 PC, 172.30.1.42)에서 발사양 노드 모두 전멸

마지막 줄이 결정적입니다. 클러스터와 무관한 PC가 보낸 패킷도 똑같이 죽었고, 목적지가 어느 노드든 결과가 같았습니다. 송신자 3종과 수신자 2종의 모든 조합에서 동일한 현상이 나온다면, 남는 공통분모는 중간 장비뿐입니다. 양쪽 노드의 NIC(Realtek RTL8168h)와 r8169 드라이버는 무죄이며, 앞서 커널 카운터가 전부 0이었던 것과도 정확히 일치합니다. 패킷은 두 호스트의 바깥에서 사라지고 있었습니다.

이 LAN에서 모든 트래픽이 통과하는 중간 장비는 하나, KT GiGA WiFi home 공유기입니다.

규명 7 — 숫자가 맞는지 검증한다

원인을 지목했다면 마지막으로 그 원인이 관측된 현상의 규모를 설명하는지 확인해야 합니다. 방향이 맞아도 크기가 안 맞으면 다른 요인이 남아 있다는 뜻이기 때문입니다.

DNS 조회 한 번은 ndots:5와 검색 도메인 때문에 최대 16개의 흐름을 만듭니다. 각 흐름의 VXLAN 외피 소스포트가 균등하게 뽑힌다고 보면, 그중 하나라도 함정에 걸릴 확률은 16/256으로 6.25%, 실측된 정지 비율은 7.5%였습니다. 예측과 관측이 오차 범위 안에서 맞습니다.

Prometheus 쪽 숫자도 맞춰봤습니다. CoreDNS 스크레이프의 3일치 스파이크는 이론값 67.5회에 대해 실측 62회였습니다. 서로 다른 두 경로에서 나온 예측이 모두 관측과 일치합니다.

TCP가 1초 만에 회복되던 현상도 이제 설명됩니다. 리눅스는 TCP를 재전송할 때 소스포트 해시를 다시 굴리므로 다른 포트로 탈출할 수 있지만, UDP는 같은 소켓에서 같은 포트로 재시도하기 때문에 한 번 함정에 걸린 흐름은 리졸버 타임아웃을 끝까지 채웁니다. 관측된 정지 중에 "조금 느렸다가 성공한" 부분 실패가 한 건도 없었던 이유가 여기에 있습니다. 이 세부까지 들어맞으면서 가설은 확정되었습니다.

근본 원인

공유기의 DHCP 서버 위장 차단 기능이 UDP 소스포트를 16비트 전체로 비교하지 않고 하위 8비트만 비교하는 펌웨어 버그로 보입니다. 진짜 DHCP 포트인 67번만 막으려던 것이, 하위 바이트가 같은 무고한 포트 255개를 함께 죽이고 있었습니다. 관리 페이지의 보안 기능은 전부 TCP와 ICMP 대상이었고 패킷 필터 목록은 비어 있어, 이 동작을 끄는 옵션 자체가 없습니다.

왜 하필 이 클러스터였나

같은 공유기를 쓰는 다른 기기들은 멀쩡한데 클러스터만 맞은 데에는 이유가 있습니다.

VXLAN은 내부 흐름마다 외피 소스포트를 해시로 정합니다. 흐름 하나가 함정에 걸릴 확률은 256분의 1이지만, 클러스터는 Pod마다 DNS 질의를 쉬지 않고 발생시키므로 흐름의 절대 수가 압도적으로 많습니다. 확률이 낮아도 시행 횟수가 크면 문제는 상시로 나타납니다.

노드가 한 대뿐이었다면 이 증상은 아예 없었을 것입니다. 오버레이를 탈 일이 없기 때문입니다. 1편에서 워커 노드를 조인하는 순간부터 조건이 갖춰져 있었던 셈입니다.

여기에 CoreDNS 구성이 피해를 키웠습니다. k3s 내장 CoreDNS는 replica가 1개이고 control-plane 노드에만 배치되므로, 워커 노드의 모든 Pod은 DNS 질의 때마다 반드시 노드를 건너야 했습니다. 노출량이 두 배가 아니라, 워커 쪽 DNS는 100% 오버레이를 탔습니다.

조치 — flannel 백엔드를 wireguard-native로

공유기 필터를 끌 수 없다면 남은 선택은 필터에 걸리지 않는 포트를 쓰는 것입니다. flannel의 wireguard-native 백엔드는 VXLAN처럼 흐름마다 포트를 바꾸지 않고 고정 포트 51820만 사용합니다. 51820 mod 256은 108이므로 함정과 무관합니다.

전환 전에 51820이 실제로 통과하는지부터 확인했습니다. 앞의 UDP 스윕 스크립트로 다섯 번 쏴서 다섯 번 모두 도착하는 것을 보고 진행했습니다.

노드 설정 변경

두 노드 모두 /etc/rancher/k3s/config.yaml에 백엔드를 명시합니다.

YAML
flannel-backend: wireguard-native

k3s 재시작

서버 노드를 먼저 재시작하고, 에이전트 노드를 이어서 재시작합니다.

Bash
# control-plane
systemctl restart k3s
# worker
systemctl restart k3s-agent

잔존 VXLAN 경로 제거

이 단계를 놓치면 전환해도 아무것도 바뀌지 않습니다. 상세는 아래에서 다룹니다.

Bash
# u1
ip route del 10.42.1.0/24 dev flannel.1
ip link del flannel.1
# u2
ip route del 10.42.0.0/24 dev flannel.1
ip link del flannel.1

검증

백엔드, 라우팅, 실제 DNS 지연을 순서대로 확인합니다.

Bash
# 백엔드가 실제로 wireguard인지
cat /var/lib/rancher/k3s/agent/etc/flannel/net-conf.json   # "Type":"wireguard"
 
# 10.42.0.0/16이 flannel-wg로만 나가는지 (flannel.1 경로가 없어야 함)
ip route | grep 10.42

전환 함정 — 지워지지 않은 옛 경로

재시작 직후 확인했을 때 flannel-wg 인터페이스가 생겼고 net-conf.json의 타입도 wireguard로 바뀌어 있었습니다. 그런데 DNS는 여전히 10초에 걸렸습니다.

원인은 라우팅 테이블에 있었습니다.

10.42.0.0/24 via 10.42.0.0 dev flannel.1 onlink   ← 더 구체적(/24)이라 우선 적용
10.42.0.0/16 dev flannel-wg scope link            ← 새 경로지만 밀림

k3s가 WireGuard 경로를 추가하면서 옛 VXLAN /24 경로를 지우지 않았고, 리눅스는 항상 더 구체적인 프리픽스를 우선하므로 트래픽은 계속 VXLAN 터널로 나가고 있었습니다. 설정도 인터페이스도 새것인데 동작만 옛것인 상태였습니다.

ip route del로 옛 경로를 지우고 flannel.1 인터페이스까지 제거하자 즉시 정상화되었습니다. 이후 k3s-agent를 다시 재시작해도 flannel.1은 부활하지 않아 영속성도 확인했습니다.

결과

전환 전후를 같은 방법으로 측정했습니다.

측정전환 전전환 후
DNS 200회 (argocd-repo-server, 워커)max 10,025ms, 7% 실패max 32ms, 0/200
DNS 120회 (longhorn-ui, 워커)max 10,025msmax 23ms, 0/120
Redis replica → master PING 150회max 7,013msmax 97ms, 실패 0

세 자릿수 밀리초 단위의 산발적 정지가 완전히 사라졌습니다. 처음 출발점이었던 replica의 probe 실패도 함께 없어졌습니다.

MTU는 조치 대상이 아니다

WireGuard는 VXLAN보다 캡슐화 오버헤드가 커서 터널 MTU가 1450이 아니라 1420입니다. 다만 CNI가 새 Pod에 부여하는 FLANNEL_MTU도 1420으로 함께 갱신되므로, 전환 이후 생성되는 Pod은 처음부터 올바른 값을 받습니다.

문제가 될 수 있는 것은 전환 이전에 떠 있던 Pod입니다. 이들은 인터페이스 MTU 1450을 그대로 유지하지만 통신이 깨지지는 않습니다. 커널이 Path MTU Discovery로 처리하기 때문이며, 실제로 MTU 1450인 Pod에서 크로스노드로 129KB를 전송해 108ms에 수신되는 것을 확인했습니다. 재배포나 노드 재부팅 시점에 자연히 1420으로 정렬되므로 전체 워크로드를 강제로 재시작할 필요는 없습니다.

남은 것

이 조치가 구제한 것은 클러스터뿐입니다. 공유기 필터는 그대로 살아 있으므로, 이 LAN의 모든 기기는 여전히 UDP 소스포트 256개 중 1개를 사용하지 못합니다. 소스포트를 무작위로 고르는 게임, VoIP, QUIC 트래픽에서 간헐적인 이상이 나타날 수 있고, 원인을 모르는 채로 겪으면 진단이 사실상 불가능한 종류의 증상입니다.

근본 해결 후보는 두 가지입니다. 노드들을 더미 스위치에 물리고 스위치만 공유기에 연결하면 노드 간 트래픽이 공유기를 우회하므로 클러스터는 VXLAN으로도 안전해지지만, LAN 전체는 여전히 영향권에 남습니다. 공유기 펌웨어 업데이트나 장비 교체만이 완치입니다.

검증은 간단합니다. 작업 PC에서 소스포트 46403으로 UDP를 노드에 쏴서 도착하면 완치된 것입니다. 앞의 스윕 스크립트를 그대로 쓰면 됩니다.

이 규명에서 남는 것

기술적 결론은 특수합니다. 특정 제조사 공유기의 펌웨어 버그를 만날 확률은 높지 않습니다. 반면 진단 과정에서 나온 실수는 훨씬 일반적입니다.

증상 지표를 원인 지표로 착각하지 않아야 합니다. 재시작 횟수, 실패 카운트, 알림 발생 여부는 임계치를 만지면 얼마든지 0이 됩니다. 조치 후에는 지표가 좋아졌는지가 아니라 원인이 제거됐는지를 물어야 하며, 이 둘은 자주 다릅니다.

표본 크기를 정하지 않고 측정하면 없는 결론을 만들어냅니다. 발생률이 수 퍼센트인 현상을 20회 표본으로 기각한 것이 이번 진단에서 가장 큰 손실이었습니다. 간헐적 장애를 다룰 때는 "몇 회를 돌려야 이 확률을 검출할 수 있는가"를 먼저 계산하고 들어가야 합니다.

증상이 유명한 사례와 닮았다는 것은 근거가 아닙니다. conntrack DNAT 경합은 실재하는 문제이고 완화책도 정석이지만, 이번 경우에는 원인이 아니었습니다. 검색으로 얻은 답을 적용해 지표가 좋아졌다면, 그것이 원인 제거인지 회피인지 반드시 구분해야 합니다. A 단독 질의로 반증 실험을 한 번만 했더라도 훨씬 일찍 방향을 틀 수 있었습니다.

최댓값의 모양은 그 자체로 정보입니다. 7,009ms처럼 설정한 타임아웃에 소수점까지 붙는 값은 "느림"이 아니라 "무응답"을 뜻하고, 10,025ms처럼 리졸버 기본값의 정수배로 떨어지는 값은 재시도 로직이 만들어낸 값입니다. 분포가 연속적인지 특정 값에 몰려 있는지만 봐도 경합과 유실을 구분할 수 있습니다.

계층을 쪼개고 변수를 하나만 남기는 것이 결국 답을 줬습니다. 로컬과 크로스노드를 분리하고, 오버레이와 물리를 분리하고, 같은 이미지를 다른 노드에서 돌려 노드 변수만 남긴 세 번의 분리가 조사 범위를 Redis에서 네트워크로, 네트워크에서 전선 구간으로 좁혔습니다. 그 후의 tcpdump와 UDP 스윕은 이미 좁혀진 범위를 확인하는 작업에 가까웠습니다.

정리

8편 요약

  1. 증상: 워커 노드 Pod의 DNS 조회가 정확히 5초 또는 10초 정지, Redis replica probe가 이에 걸려 17회 재시작
  2. 오진 1: probe 타임아웃 상향으로 재시작은 멎었지만 실패 이벤트는 계속 — 마진만 늘었고 원인은 그대로였습니다
  3. 오진 2: master CPU 쓰로틀(12.7%)은 실재했고 평균은 개선됐지만, 타임아웃에 붙는 max 값은 CPU로 설명되지 않습니다
  4. 오진 3: DNS를 20회 표본으로 기각 — 100회로 늘리자 max 10,025ms가 나타났습니다
  5. 오진 4: conntrack A/AAAA 경합 이론은 A 단독 질의도 죽는 것으로 반증됐습니다
  6. 근본 원인: 공유기가 UDP 소스포트 하위 8비트만 비교해 0x43인 패킷을 100% 폐기. VXLAN 외피 포트가 256분의 1 확률로 여기에 걸렸습니다
  7. 조치: flannel 백엔드를 wireguard-native(고정 포트 51820)로 전환. 전환 시 옛 VXLAN /24 경로 제거가 필수입니다
  8. 결과: DNS max 10,025ms → 32ms, 실패 0/200

참고 자료

  • flannel 백엔드 문서
  • k3s 네트워킹 옵션
  • Kubernetes DNS 정책과 Pod dnsConfig
  • RFC 7348 — Virtual eXtensible Local Area Network (VXLAN)
  • resolv.conf(5) — glibc 리졸버 옵션
  • WireGuard 프로토콜 개요
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

Infra

홈 k3s 클러스터 구축기 (1) — Ubuntu 설치부터 워커 노드 조인까지

4코어 8GB 미니PC 2대로 시작하는 경량 Kubernetes 홈 클러스터 구축 과정

읽기
Infra

홈 k3s 클러스터 구축기 (2) — 외부 노출과 TLS 인증서

공유기 포트포워딩, 도메인 연결, cert-manager를 통한 HTTPS 적용 과정

읽기
Infra

홈 k3s 클러스터 구축기 (3) — ArgoCD와 GitOps 배포 파이프라인

ArgoCD App-of-Apps, Helm Chart 분리 설계, GitHub Actions CI를 통한 GitOps 자동 배포 구축 과정

읽기
Infra

홈 k3s 클러스터 구축기 (6) — Longhorn 분산 스토리지

CSI 기반 분산 스토리지 개념과 Longhorn ArgoCD 배포, 데이터베이스 워크로드 적용

읽기
이전 글홈 k3s 클러스터 구축기 (7) — Prometheus + Grafana 모니터링
목차
  • 들어가며
  • 증상 — replica의 probe는 혼자 판단하지 않는다
  • 오진 1 — "재시작이 0회니까 해결됐다"
  • 오진 2 — master의 CPU 쓰로틀
  • 오진 3 — 20회 표본으로 DNS를 기각하다
  • 오진 4 — 교과서적인 답을 검증 없이 가져다 쓰다
  • 규명 1 — 변수를 하나만 남긴다
  • 규명 2 — 경로를 계층으로 쪼갠다
  • 규명 3 — 흔한 후보부터 지운다
  • 규명 4 — 여섯 지점에서 동시에 찍는다
  • 규명 5 — 0x43
  • 규명 6 — 삼각측량으로 범인을 좁힌다
  • 규명 7 — 숫자가 맞는지 검증한다
  • 왜 하필 이 클러스터였나
  • 조치 — flannel 백엔드를 wireguard-native로
    • 전환 함정 — 지워지지 않은 옛 경로
    • 결과
    • MTU는 조치 대상이 아니다
  • 남은 것
  • 이 규명에서 남는 것
  • 정리
  • 참고 자료