한 줄 요약: L7 정책만 붙이면 HTTP가 죽는 문제를 Envoy 실행 형태·masquerade·tproxy까지 바꿔가며 쫓았지만 고치지 못했고, Cilium 자체 연결성 테스트가 L7 8종 전부 실패하는 것을 확인해 “내 정책 문제가 아니라 프록시 경로 전체”로 원인을 좁힌 뒤, L4 관측으로 운영하며 업스트림 수정을 기다리는 쪽으로 정리했습니다.
이 글의 위치: 기술 블로그 → 인프라 · 로컬 서버 운영. 작성·검증 방식은 Editorial Policy 참조.
홈랩 검증기 3편(마지막)입니다. 2편에서 "정상으로 보이는 신호"를 걷어냈다면, 이번엔 그 방법으로도 원인을 못 찾은 장애를 다룹니다. 시리즈 시작은 1편 파드를 지워도 데이터는 남아 있었다입니다.
이 글에서 한 일 — 한눈에
TCP 는 붙는데 HTTP 만 죽는 고장을 계층별로 지워 나간 기록입니다. 이 글은 해결로 끝나지 않지만, 뒤에 추기가 붙었습니다.
httpprobe 를 쓰는 파드만 골라서 죽었습니다 —exec·tcpprobe 파드는 멀쩡했습니다.- Cilium 에서 HTTP 를 누가 처리하는지 정리했습니다 — L3/L4 는 eBPF 가 커널에서, L7 만 Envoy 가 사용자 공간에서 맡습니다.
- standalone 과 embedded 의 차이를 짚었습니다 — 기능 차이가 아니라 분리의 대가 문제입니다.
- embedded 로 전환해 xDS 결함을 걷어냈습니다 — 증상이 무응답에서 503 으로 바뀌었습니다.
- 데이터패스 설정 두 개를 더 바꿔봤습니다 — 하나는 아무 일도 하지 않는 no-op 이었습니다.
- 가장 먼저 했어야 할 검증을 마지막에 돌렸습니다 — Cilium 자체 연결성 테스트 스위트.
- 버전으로 도망갈 곳도 없었습니다 — k8s 1.36 을 지원하는 Cilium 은 1.20 뿐입니다.
- 못 고친 채로 남겼습니다 — 잃은 것은 HTTP RED 하나이고 L4 관측은 온전합니다.
- 추기 — 되돌아갔고, 그러자 원인이 드러났습니다 — 다음 날 같은 고장이 전혀 다른 얼굴로 재발했습니다.
아래부터 순서대로 풀어씁니다.
http probe 파드만 골라서 죽었습니다
관측 작업을 하다가 파드 상태를 제대로 세어 봤습니다(2편의 그 교훈 덕분입니다). 결과가 이상했습니다.
| probe 종류 | 파드 | 상태 |
|---|---|---|
exec |
DB 2개 | ✅ Ready |
tcp |
프런트 3개 | ✅ Ready |
http |
서비스 9개 | ❌ 전부 NotReady |
probe 종류로 정확히 갈렸습니다. 우연일 수 없습니다.
노드에서 직접 찔러보니 더 이상했습니다.
$ nc -z <podIP> 80 → 연결 성공
$ curl http://<podIP>/ → 헤더 대기 중 타임아웃
TCP는 붙는데 HTTP만 응답이 없습니다. L4는 멀쩡하고 L7만 죽은 것입니다. 그래서 CNI 장애로 보이지 않았습니다 — DNS 조회도, 서비스 VIP 접근도, 파드 간 통신도 전부 정상이었으니까요.
Cilium에서 HTTP는 누가 처리하나
이 클러스터의 CNI는 Cilium입니다. 대부분의 처리는 eBPF로 커널에서 끝납니다. L3/L4 라우팅·정책·로드밸런싱은 사용자 공간을 거치지 않습니다.
그런데 HTTP를 이해해야 하는 일은 eBPF만으로 안 됩니다.
- 경로·메서드 단위 정책 (
GET /orders같은) - Hubble의 HTTP RED 메트릭(요청수·에러율·지연)
- 사이드카 없는 서비스메시의 L7 기능
이때 Cilium은 해당 트래픽을 Envoy로 리다이렉트합니다. eBPF가 패킷을 가로채 Envoy로 넘기고, Envoy가 HTTP로 파싱한 뒤 원래 목적지로 보냅니다.
여기서 중요한 성질이 하나 있습니다. 이 리다이렉트는 L7 규칙을 가진 CiliumNetworkPolicy가 붙은 엔드포인트에만 일어납니다. 정책이 없으면 Envoy는 경로에 아예 없습니다.
그리고 그게 이 장애의 타이밍을 설명합니다.
14:53 cilium 에이전트 재시작
└ cilium-envoy 는 별도 DaemonSet 이라 그대로 남음
14:53 envoy: access_log.sock 없음
envoy: Ignoring unwatched type URL ...cilium.NetworkPolicy ← 핵심
(에이전트는 계속 정책을 push, Envoy 는 전부 버림)
15:28 L7 정책 부착 → 리다이렉트 시작 → 그 순간 터짐
원인(에이전트 재시작)과 증상(정책 부착) 사이가 35분입니다. 그 사이 아무 증상이 없습니다. 인과가 끊겨 보이는 이유입니다.
standalone과 embedded — 분리의 대가
Envoy를 어디서 실행하느냐의 차이입니다. 기능 차이가 아닙니다.
| 항목 | standalone (차트 기본값) | embedded |
|---|---|---|
| 실행 형태 | 별도 cilium-envoy DaemonSet |
cilium 에이전트 프로세스 내부 |
| 프로세스 수 | 2개 | 1개 |
| 설정 전달 | 유닉스 소켓 xDS | 프로세스 내부 |
| 생명주기 | 독립 — 에이전트를 재시작해도 그대로 | 공유 — 같이 뜨고 같이 죽음 |
| 메모리 회계 | 별도 파드 | 에이전트에 합산 |
| 에이전트 재시작 시 L7 | 유지됨 | 순단 |
standalone이 기본값인 이유는 분리의 이점 때문입니다. 에이전트를 재시작해도 L7 트래픽이 안 끊기고, 메모리도 섞이지 않습니다. 노드가 많고 L7 트래픽이 많을수록 유리합니다.
그 분리가 여기서는 정확히 문제의 원인이었습니다. 에이전트가 xDS 스트림과 소켓을 소유하는데, 에이전트만 재시작하면 Envoy는 살아 있는 채로 설정 공급이 끊깁니다.
embedded로 바꾸자 그 결함은 사라졌습니다. Ignoring unwatched type URL 경고가 없어지고 정책이 정상적으로 흐릅니다.
그런데 503은 남았습니다.
배제법 — 아닌 곳을 하나씩 지우기
증상이 바뀐 건 진전입니다. 전에는 순수 무응답이었는데, 이제는 Envoy가 응답을 줍니다.
HTTP/1.1 503 Service Unavailable
server: envoy
upstream connect error or disconnect/reset before headers.
retried and the latest reset reason: connection timeout
Envoy가 요청을 받아서 HTTP로 파싱까지 했고, 업스트림(파드)으로 붙다가 타임아웃했습니다. 고장 지점이 "설정 전달"에서 "프록시↔업스트림 연결"로 이동한 것입니다.
Hubble로 흐름을 보면 파드는 멀쩡합니다.
host:40418 -> app:3000 to-endpoint FORWARDED (SYN) ← Envoy → 파드
host:40418 <- app:3000 to-stack FORWARDED (SYN,ACK) ← 파드는 정상 응답
host:40418 -> app:3000 to-proxy FORWARDED (RST) ← 핸드셰이크 미완료
파드가 SYN-ACK을 제때 돌려주는데, 그게 Envoy 소켓까지 가지 못합니다.
여기서부터는 가설을 세우고 하나씩 측정해서 지웠습니다.
| 가설 | 측정 방법 | 결과 |
|---|---|---|
| eBPF가 패킷을 버린다 | cilium-dbg monitor -t drop |
drop 0건. SYN-ACK은 -> stack으로 정상 전달 |
방화벽 INPUT DROP이 먹는다 |
요청 전후 iptables 정책 DROP 카운터 |
증분 0 |
rp_filter가 역경로 검사로 버린다 |
전 인터페이스 /proc/sys/.../rp_filter |
전부 0 |
| VPN이 프록시 mark를 덮어쓴다 | mangle 규칙 마스크 대조 | 무관. 마스크가 겹치지 않음 |
| masquerade가 SNAT한다 | iptables -t nat -S 규칙 매칭 확인 |
해당 없음 (양쪽 다 클러스터 대역) |
| 커널이 패킷을 거부한다 | /proc/net/netstat 전후 델타 |
ListenDrops·PAWS·csum 전부 0. TCPSynRetrans만 증가 = 거부가 아니라 미도달 |
TPROXY 규칙 자체는 살아서 동작했습니다(카운터가 실제로 증가). 즉 패킷은 마킹되고 리다이렉트까지 정상인데, 커널이 그걸 Envoy의 transparent 소켓으로 넘겨주지 않는 상태였습니다.
설정 두 개를 더 바꿔봤습니다
데이터패스 설정도 시도했습니다. 여기서 예상 못 한 걸 배웠습니다.
hostLegacyRouting: true — 적용해도 아무 변화가 없었습니다. 이유는 로그에 있었습니다.
BPF host routing requires enable-bpf-masquerade.
Falling back to legacy host routing (enable-host-legacy-routing=true).
이미 legacy였습니다. masquerade가 iptables 방식이라 eBPF host routing이 통째로 비활성이었던 것입니다. 설정을 바꿔도 아무 변화가 없을 때는 "효과가 없다"가 아니라 "이미 그 상태였다"를 먼저 의심해야 합니다.
bpf.masquerade: true — 데이터패스가 표준 경로로 올라갔습니다(Host: Legacy → BPF). 503은 그대로였지만, kube-proxy 대체와 짝이 맞는 권장 구성이라 되돌리지 않고 유지했습니다.
여기서도 하나 걸렸습니다. ConfigMap만 바꾸면 반영되지 않습니다. cilium은 기동 시 설정을 읽으므로 DaemonSet 롤이 반드시 뒤따라야 합니다.
가장 먼저 했어야 할 검증
여기까지 오고 나서야 떠올린 게 있습니다. Cilium 에는 자체 연결성 테스트 스위트가 있습니다.
$ cilium connectivity test --test 'l7|http'
❌ echo-ingress-l7 ❌ client-egress-l7
❌ echo-ingress-l7-named-port ❌ client-egress-l7-port-range
❌ client-egress-l7-method ❌ client-egress-l7-named-port
❌ client-egress-l7-method-port-range ❌ client-egress-l7-tls-headers-sni
L7 테스트 8종이 ingress·egress 양쪽 모두 실패했습니다. 실패 형태도 제 증상과 같습니다(curl exit 28 = 타임아웃).
이 한 번의 실행이 그때까지 열려 있던 가능성을 통째로 닫았습니다. 제 정책이 이상한 것도, 방향(ingress/egress)의 문제도, 특정 워크로드의 문제도 아니었습니다. 이 클러스터의 L7 프록시 전체입니다.
교훈이 하나 더 붙습니다 — 자기가 만든 정책을 의심하기 전에, 벤더가 제공하는 표준 검증부터 돌려야 합니다. 제 정책을 여러 모양으로 바꿔보느라 쓴 시간은 이 명령 하나로 절약할 수 있었습니다.
버전으로 도망갈 곳도 없었습니다
"이전 버전으로 내리면 되지 않나"가 자연스러운 다음 수순입니다. 1차 출처를 확인했습니다.
| Cilium | 문서가 명시하는 지원 k8s | 이 클러스터(v1.36) |
|---|---|---|
| 1.19.6 | 1.31 1.32 1.33 1.34 |
❌ 미지원 |
| 1.20.0 | 1.33 1.34 1.35 1.36 |
✅ 유일한 선택지 |
프리릴리스까지 조회해도 1.20.0 이 최신이고, 1.20.x 패치는 아직 없습니다. 위로도 아래로도 갈 곳이 없습니다.
여기서 한 가지 함정을 더 만났습니다. 헬름 차트의 제약은 두 버전 모두 kubeVersion: '>= 1.21.0-0' 로 하한만 있습니다. 즉 --version 1.19.6 으로 설치해도 아무 경고 없이 성공합니다. 설치가 된다는 것과 지원된다는 것은 다른 문장인데, 헬름만 보면 구분할 수 없습니다.
못 고쳤지만, 잃은 것과 잃지 않은 것은 분명합니다
결론부터 쓰면 설정으로 풀 수 있는 범위를 벗어났습니다. 남은 고장 지점은 하나입니다.
| 계층 | 상태 |
|---|---|
| eBPF 리다이렉트 / TPROXY | ✅ 정상 (drop 0) |
| Envoy 설정 수신(xDS) | ✅ 정상 (embedded 전환으로 해결) |
| Envoy HTTP 파싱 | ✅ 정상 (503을 직접 생성) |
| 메트릭 파이프라인 | ✅ 정상 (그 503이 메트릭에 기록됨) |
| Envoy → 파드 업스트림 연결 | ❌ 여기 하나 |
마지막 확인이 의미가 있었습니다. 503을 내는 동안 메트릭을 봤더니 status="503"으로 정확히 집계되고 있었습니다. eBPF → Envoy → Prometheus → Grafana 전 구간이 멀쩡하다는 뜻입니다. 고장난 것은 딱 한 구간입니다.
그리고 관측이 사라진 게 아닙니다.
| 상태 | |
|---|---|
| HTTP RED (요청수·에러율·지연) | ❌ 사용 불가 |
| L4 flow 관측 | ✅ 완전 정상 — 10분간 40,599 flow, 호출 쌍 152개 |
| 서비스 맵(호출 토폴로지) | ✅ 정상 |
| 네트워크 정책(L3/L4) | ✅ 정상 |
서비스 간 호출 관계·트래픽량·드롭은 전부 보입니다. HTTP 계층만 빠진 상태입니다.
정리 — 못 고친 것도 기록이 됩니다
이 글은 시리즈에서 유일하게 해결로 끝나지 않습니다. 그런데 남은 게 없지는 않습니다.
- 아닌 곳 여섯 군데를 측정으로 지웠습니다. 다음에 이어받는 사람(미래의 저 포함)이 같은 가설을 다시 검증하지 않아도 됩니다.
- 고장 지점이 한 구간으로 좁혀졌습니다. "L7이 안 된다"에서 "Envoy→파드 업스트림 연결이 안 된다"로 문장이 짧아졌습니다.
- 증상 변화가 진전이라는 걸 배웠습니다. 무응답 → 503은 실패가 아니라 고장 지점이 이동했다는 신호입니다.
운영에서 제일 나쁜 상태는 못 고친 게 아니라, 못 고쳤는데 왜 못 고쳤는지도 안 남는 것입니다. 그러면 다음 사람이 처음부터 다시 시작합니다.
세 편을 관통하는 규칙은 하나였습니다. 믿음은 측정으로만 바꾼다. 1편에서는 그 규칙이 작업 방향을 바꿨고, 2편에서는 거짓 신호를 걷어냈고, 3편에서는 못 고친 문제의 경계를 그렸습니다.
추기 — 되돌아갔고, 그러자 원인이 드러났습니다
위 글을 쓴 다음 날, 같은 고장이 전혀 다른 얼굴로 돌아왔습니다. 이번에는 조용하지 않았습니다.
embedded 가 standalone 으로 되돌아가 있었습니다
본문에서 embedded 로 전환하며 cilium-envoy DaemonSet 을 지웠다고 썼습니다. 그런데 클러스터를 다시 보니 Envoy: external 이었고 DaemonSet 이 1/1 로 살아 있었습니다. 생성 시각을 재보니 제가 지웠다고 쓴 뒤였습니다.
$ kubectl -n kube-system get ds cilium-envoy -o jsonpath='{.metadata.creationTimestamp}'
2026-08-03T12:49:54Z # 지운 뒤에 다시 만들어졌다
standalone 을 지정한 values 가 재적용되면서 전환이 통째로 되돌아간 것입니다. 되돌린 기억이 없다는 게 핵심입니다 — 선언한 값과 클러스터의 실제 상태가 갈라졌는데, 어느 쪽도 그 사실을 말해주지 않았습니다.
이번에는 CPU 로 나타났습니다
노드가 CPU 72.5% 에서 내려오지 않았습니다. 4 코어짜리 집 PC 에서 load 가 6.4 였으니 코어당 124%, 대기열이 상시 밀려 있는 상태입니다. 상위 소비자를 세워 보면 이렇습니다.
cilium-agent 82%
promtail 64%
containerd 43%
cilium-envoy 28%
loki 19%
promtail 과 Loki 가 상위에 있으니 관측 스택을 의심하게 됩니다. 저도 거기서 시작했고, 로그 수집 파이프라인을 먼저 손봤습니다. 효과가 없었습니다.
전부 증상이었습니다. 로그를 옮기는 쪽이 아니라 만드는 쪽을 봐야 했습니다.
# cilium-agent
level=info msg=OnStreamRequest xdsTypeURL=type.googleapis.com/cilium.NetworkPolicy
level=info msg=OnStreamResponse xdsTypeURL=type.googleapis.com/cilium.NetworkPolicy
# cilium-envoy
warning [config] Ignoring unwatched type URL type.googleapis.com/cilium.NetworkPolicy
에이전트가 정책을 밀고, Envoy 가 “구독하지 않은 타입”이라며 버리고, 에이전트가 다시 미는 루프였습니다. 초당 1,232 줄(에이전트 448 · Envoy 784). containerd 가 그 스트림을 나르고 promtail 이 읽고 Loki 가 적재하면서, 4 코어 중 약 2.4 코어가 이 한 줄짜리 오해에 쓰이고 있었습니다.
원인은 DaemonSet 세대 드리프트였습니다
두 DaemonSet 의 세대를 나란히 보면 바로 드러납니다.
$ kubectl -n kube-system get ds cilium cilium-envoy \
-o custom-columns=NAME:.metadata.name,GEN:.metadata.generation
cilium 11 # 에이전트는 열한 번 재구성됐고
cilium-envoy 1 # Envoy 는 한 번도 갱신되지 않았다
에이전트는 envoy-xds-mode: ads 로 바뀌어 있었는데 Envoy 는 그 이전 방식의 부트스트랩을 그대로 들고 있었습니다. Envoy 의 부트스트랩은 기동할 때 한 번 만들어지므로, 에이전트만 재구성하면 둘의 대화가 어긋납니다.
조치는 본문에서 이미 한 번 썼던 그 한 줄이었습니다.
kubectl -n kube-system rollout restart ds/cilium-envoy
| 조치 전 | 조치 후 | |
|---|---|---|
| 노드 CPU | 72.5% | 16.7% |
| load (1분) | 6.40 | 1.40 |
| Envoy 로그 | 784 줄/초 | 0 |
| promtail | 64% | 2.1% |
점검 스크립트가 이 고장을 “정상”으로 보고하고 있었습니다
가장 불편한 부분입니다. 저는 이 실패 모드를 이미 알고 있었고, 검사 항목까지 만들어 뒀습니다. 그런데 셋 다 통과했습니다.
- 기동 순서 비교 — 두 파드가 같은 초에 떴으므로 통과
initial fetch timed out탐색 — 스트림이 살아 있어 애초에 찍히지 않으므로 통과- L7 정책 개수 — 정책이 0 개라 아무 신호도 없음
세 검사 모두 “스트림이 끊겼는가“를 묻고 있었는데, 이번 고장은 스트림이 멀쩡히 붙은 채로 시끄러운 것이었습니다. 끊김에 관한 어떤 명제도 참이 아니니 전부 합격이 나옵니다. 그래서 검사를 하나 더 붙였습니다 — 로그의 양을 직접 재는 것입니다.
kubectl -n kube-system logs ds/cilium-envoy --since=3s | grep -c 'unwatched type URL'
그래서 본문의 결론은 바뀌었나
바뀌지 않았습니다. 여기서 고친 것은 xDS 전달 계층이고, 본문이 끝까지 붙잡고 있던 503 — Envoy 에서 파드로 가는 업스트림 연결은 여전히 그대로입니다. 두 고장은 뿌리가 다릅니다.
다만 하나가 정리됐습니다. 본문에서 “embedded 로 바꿔서 xDS 결함은 해소됐다”고 썼는데, 정확하게는 실행 형태를 바꾼 것이 아니라 Envoy 를 다시 띄운 것이 해결이었습니다. standalone 이라도 에이전트 설정을 바꾼 뒤 Envoy 를 함께 롤링하면 같은 결과가 나옵니다. 실행 형태의 문제로 읽었던 것이 실은 갱신 순서의 문제였습니다.
교훈: 같은 뿌리의 고장이 정반대 증상으로 나타날 수 있습니다. 한 번은 침묵(스트림 끊김)으로, 한 번은 소음(구독 불일치)으로요. 침묵을 찾는 검사만 갖고 있으면 소음은 통과합니다.
참고
- Cilium — eBPF Datapath — eBPF가 처리하는 계층
- Cilium — Layer 7 Protocol Visibility — L7 정책이 있어야 HTTP 메트릭이 생기는 이유
- Cilium — Envoy — standalone/embedded 구성
- Cilium — kube-proxy replacement —
bpf.masquerade·host routing 상호작용 - Cilium — Hubble — flow 관측과
hubble observe - Envoy — 503 upstream connect error — 업스트림 연결 실패 응답
- Linux
tproxy/ip rule—fwmark기반 정책 라우팅과 transparent 소켓
답글 남기기