엔드포인트는 정상인데 브라우저만 404일 때
이 글은 K8s 네트워킹 심화 가이드 4편입니다. 3편에서 kubectl get endpointslice로 백엔드 Pod가 Service에 정상 등록된 것까지 확인했다는 전제에서 출발합니다.
전형적인 상황은 이렇습니다.
kubectl port-forward svc/myapp 8080:80
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/
# 200
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
# 404포트포워딩이 200을 주는 순간, Service → Pod 구간은 이미 무죄입니다. 문제는 그 앞단, 즉 L7 진입 경로에 있습니다.
[클라이언트]
│ ① DNS / 방화벽 / 보안그룹
▼
[Cloud LB (ELB/GLB/Azure LB)]
│ ② NodePort or LB 타깃 등록 / health check
▼
[Ingress Controller Pod (ingress-nginx 등)]
│ ③ Ingress 리소스 매칭: host / path / ingressClassName
▼
[Service (ClusterIP)]
│ ④ selector → EndpointSlice ← 3편 범위
▼
[Pod]명시적 분기 안내: kubectl get endpointslice -l kubernetes.io/service-name=myapp 결과에서 ADDRESSES가 비어 있거나 <none>이면 이 글이 아니라 3편(Endpoints <none>·no endpoints available 원인 편)으로 가야 합니다. 이 글은 엔드포인트가 채워져 있는데도 외부 접근만 실패하는 ①~③ 구간 전용입니다.
Ingress Controller 자체의 기본 구성과 라우팅 개념이 아직 낯설다면 Kubernetes Ingress 완전 가이드 — Nginx Ingress Controller로 외부 트래픽 라우팅을 먼저 훑고 오시면 이 글의 진단 절차가 훨씬 빨리 읽힙니다.
응답 원문 판정표 — 문자열 하나로 원인 계층 직행
브라우저나 curl -v에 찍힌 문자열만으로 원인 계층을 좁힐 수 있습니다. 아래 표에서 자기 상황을 먼저 찾으세요.
| 응답 원문 / 로그 | 원인 계층 | 다음 행동 |
|---|---|---|
HTTP/1.1 404 Not Found + 헤더 Server: nginx + 바디 <html><head><title>404 Not Found</title></head><body><center><h1>404 Not Found</h1></center><hr><center>nginx</center></body></html> | ③ Ingress 리소스 매칭 실패 — host/path/ingressClassName 중 하나가 안 맞아 default backend로 떨어짐 | 확진 6단계의 ①~③ |
HTTP/1.1 503 Service Temporarily Unavailable + 컨트롤러 로그 no healthy upstream 또는 upstream connect error | ③→④ 경계 — Ingress는 매칭됐으나 backend Service 이름·포트 불일치, 또는 upstream이 빈 상태 | 확진 6단계의 ④~⑤ |
HTTP/1.1 502 Bad Gateway + 로그 upstream sent too big header while reading response header from upstream | ③ 프록시 버퍼 부족(대형 Set-Cookie/JWT 헤더) | 조치표 proxy-buffer-size 항목 |
curl: (28) Operation timed out / 브라우저 ERR_CONNECTION_TIMED_OUT — 응답 헤더 자체가 없음 | ①~② LB·보안그룹·NodePort 등 Ingress 이전 구간 | 확진 6단계의 ⑥ + 결론부 실패 분기 |
TLS 경고 + 인증서 CN이 Kubernetes Ingress Controller Fake Certificate | ③ TLS Secret 미탑재 또는 tls.hosts와 요청 host 불일치 | 조치표 TLS 항목 |
판정의 핵심은 응답 헤더가 존재하는가입니다. 헤더가 하나라도 돌아왔다면 Ingress Controller까지는 패킷이 닿은 것이고, 아예 없다면 그 앞에서 끊긴 것입니다.
30초 확진 6단계
순서대로 복붙해서 실행합니다. 각 단계의 정상/이상 출력을 나란히 비교하세요.
① Ingress가 컨트롤러에 인식됐는지
kubectl get ingress -o wide정상 출력:
NAME CLASS HOSTS ADDRESS PORTS AGE
myapp nginx example.com 203.0.113.10 80, 443 12m이상 출력:
NAME CLASS HOSTS ADDRESS PORTS AGE
myapp <none> * 80 12mCLASS가 <none>이거나 ADDRESS가 계속 공란이면 컨트롤러가 이 Ingress를 아예 집어가지 않은 상태입니다. ③으로 건너뜁니다.
② describe로 Events와 default backend 확인
kubectl describe ingress myapp정상:
Rules:
Host Path Backends
---- ---- --------
example.com
/ myapp:80 (10.244.1.23:8080,10.244.2.11:8080)
Events:
Type Reason Age From Message
Normal Sync 2m nginx-ingress-controller Scheduled for sync이상 (두 가지 대표 패턴):
Default backend: <default> (<error: endpoints "default-http-backend" not found>) / myapp:8080 (<none>)앞은 매칭 규칙이 하나도 살아있지 않은 것, 뒤는 Service는 찾았지만 upstream이 비어 있는 503 직행 패턴입니다. Events에 Sync 자체가 없으면 컨트롤러가 이 리소스를 watch하지 않고 있다는 뜻입니다.
③ ingressClassName 확인 (v1.22+ 필수 체크)
kubectl get ingress myapp -o jsonpath='{.spec.ingressClassName}{"\n"}'
kubectl get ingress myapp -o jsonpath='{.metadata.annotations}{"\n"}'
kubectl get ingressclass정상:
nginx
{"kubernetes.io/ingress.class":"nginx"}
NAME CONTROLLER PARAMETERS AGE
nginx k8s.io/ingress-nginx <none> 30d이상: 첫 명령이 빈 줄만 출력되고 어노테이션에만 kubernetes.io/ingress.class가 있는 경우. 레거시 어노테이션은 Kubernetes 1.22에서 폐기 대상이 되었고, 컨트롤러 버전·기동 플래그에 따라 조용히 무시될 수 있습니다. kubectl get ingressclass 결과의 NAME과 spec.ingressClassName 값이 문자 단위로 동일해야 합니다.
④ backend Service 이름·포트 대조
kubectl get ingress myapp -o jsonpath='{range .spec.rules[*].http.paths[*]}{.backend.service.name}{" -> "}{.backend.service.port}{"\n"}{end}'
kubectl get svc myapp -o jsonpath='{range .spec.ports[*]}{.name}{" "}{.port}{" -> "}{.targetPort}{"\n"}{end}'정상:
myapp -> {"number":80}
http 80 -> 8080이상:
myapp -> {"name":"https"}
http 80 -> 8080Ingress가 참조한 포트 이름이 Service의 포트 이름 목록에 없으면 upstream이 만들어지지 않고 503이 납니다. 숫자로 참조할 때는 targetPort가 아니라 Service의 port 값을 써야 합니다.
⑤ 컨트롤러 로그 실시간 확인
kubectl logs -n ingress-nginx deploy/ingress-nginx-controller --tail=50정상(요청이 실제로 들어오고 200):
10.0.1.5 - - [10/Aug/2026:04:11:02 +0000] "GET / HTTP/1.1" 200 1256 "-" "curl/8.4.0" 84 0.004 [default-myapp-80] [] 10.244.1.23:8080 1256 0.004 200이상:
2026/08/10 04:12:31 [error] 31#31: *117 no live upstreams while connecting to upstream, client: 10.0.1.5, server: example.com, request: "GET / HTTP/1.1", upstream: "http://upstream-default-backend/"2026/08/10 04:13:02 [error] 31#31: *120 upstream sent too big header while reading response header from upstream, client: 10.0.1.5, ...대괄호 안의 [default-myapp-80]가 비어 있거나([]) upstream-default-backend로 찍히면 매칭 실패가 확정입니다.
⑥ DNS 요인 배제
curl -H 'Host: example.com' http://<LB-IP>/ -v<LB-IP>는 kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'로 얻습니다.
정상:
< HTTP/1.1 200 OK
< Server: nginx이상 A — Host 헤더를 주면 200인데 도메인으로는 실패 → DNS/CNAME 문제. 클러스터는 정상입니다.
이상 B — 여기서도 curl: (28) 타임아웃 → LB·보안그룹·NodePort 구간. 아래 실패 분기로.
원인별 조치표
| 증상 | 확인 명령 | 수정 | 검증 |
|---|---|---|---|
404 default backend, CLASS <none> | kubectl get ingress -o wide | ingressClassName 추가 | kubectl get ingress -o wide에 CLASS·ADDRESS 표시 |
/api는 되고 /api/users만 404 | kubectl get ing myapp -o yaml | grep pathType | pathType: Prefix로 변경 | curl -o /dev/null -w '%{http_code}' https://example.com/api/users |
503, describe에 (<none>) | 위 확진 ④ | Service 포트 이름/번호 정정 | kubectl describe ing myapp에 IP 목록 표시 |
502 + too big header | 컨트롤러 로그 | proxy-buffer-size 어노테이션 | 로그에 에러 미재현, 200 응답 |
백엔드가 /api 프리픽스를 모름 | 앱 라우팅 확인 | rewrite-target + 캡처그룹 | 로그의 upstream 경로 확인 |
| Fake Certificate 경고 | kubectl get secret myapp-tls | spec.tls 연결 | curl -vI https://example.com 인증서 CN |
1) ingressClassName 추가
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
namespace: default
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: myapp
port:
number: 802) pathType 수정 — 404의 숨은 단골
Exact는 문자열이 정확히 같아야만 매칭됩니다. /api에 Exact를 쓰면 /api/users는 매칭되지 않고 default backend로 떨어집니다.
paths:
- path: /api
pathType: Prefix # Exact → Prefix
backend:
service:
name: myapp
port:
number: 80| pathType | /api 요청 | /api/users | /apiv2 |
|---|---|---|---|
Exact | 매칭 | 미매칭 | 미매칭 |
Prefix | 매칭 | 매칭 | 미매칭(경로 요소 단위 비교) |
ImplementationSpecific | 컨트롤러 구현에 위임 | 구현별 상이 | 구현별 상이 |
ImplementationSpecific은 컨트롤러를 교체하는 순간 라우팅이 바뀔 수 있어 이식성이 필요하면 피하는 편이 안전합니다.
3) backend Service 포트 정정
# Service
apiVersion: v1
kind: Service
metadata:
name: myapp
spec:
selector:
app: myapp
ports:
- name: http
port: 80
targetPort: 8080
---
# Ingress backend — name 참조 시 Service의 ports[].name과 동일해야 함
service:
name: myapp
port:
name: http4) proxy-buffer-size (502 too big header)
metadata:
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"기본값이 작아 대형 Set-Cookie나 긴 JWT를 담은 응답 헤더에서 터집니다. 8k → 16k → 32k 순으로 올리며 확인합니다.
5) rewrite-target + 정규식 캡처그룹
/api/users 요청을 백엔드에 /users로 넘기려는 경우입니다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp
annotations:
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
- host: example.com
http:
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: myapp
port:
number: 80자주 틀리는 지점: 캡처그룹 번호를 $1로 쓰면 / 또는 빈 문자열만 전달됩니다. (/|$)가 첫 번째 그룹이므로 실제 경로는 $2입니다. 정규식 경로에는 pathType: Prefix가 아니라 ImplementationSpecific을 써야 의도대로 동작합니다.
6) TLS Secret 연결
kubectl create secret tls myapp-tls --cert=tls.crt --key=tls.key -n defaultspec:
ingressClassName: nginx
tls:
- hosts:
- example.com
secretName: myapp-tls
rules:
- host: example.com
...tls.hosts의 값과 rules.host, 그리고 인증서의 SAN이 세 곳 모두 일치해야 Fake Certificate가 사라집니다. Secret은 Ingress와 같은 네임스페이스에 있어야 합니다.
조용히 실패하는 함정 3가지
- 네임스페이스 경계: Ingress는 같은 네임스페이스의 Service만 참조합니다. 다른 ns의 Service 이름을 적으면 에러 없이 upstream만 비고 503이 납니다.
ExternalName타입 Service를 같은 ns에 두어 우회하는 방법이 있습니다. - 동일 host 중복 선언: 여러 Ingress가 같은 host를 선언하면 경로가 병합되지만, 같은 path가 겹치면 대체로 생성 시각이 앞선 리소스가 우선합니다.
kubectl get ingress -A | grep example.com으로 중복부터 확인하세요. - IngressClass 이름 불일치: 컨트롤러 기동 인자
--ingress-class=nginx와 IngressClass 리소스 이름이 다르면 아무것도 매칭되지 않습니다.kubectl -n ingress-nginx get deploy ingress-nginx-controller -o yaml | grep ingress-class로 대조합니다.
컨트롤러별 차이와 버전 분기
같은 증상이라도 컨트롤러마다 메시지와 기본값이 다릅니다.
| 항목 | ingress-nginx | Traefik | AWS ALB Controller |
|---|---|---|---|
| 매칭 실패 메시지 | nginx 기본 404 페이지 (<center>nginx</center>) | 404 page not found 평문 | ALB 기본 404 (JSON/빈 바디, Server: awselb/2.0) |
| upstream 없음 | 503 + 로그 no live upstreams | 503 Service Unavailable | 502/타깃 unhealthy |
| 어노테이션 접두사 | nginx.ingress.kubernetes.io/ | traefik.ingress.kubernetes.io/ | alb.ingress.kubernetes.io/ |
| 프록시 타임아웃 기본 | 60초 계열(read/send) | 무제한 계열, 명시 설정 권장 | idle timeout 60초 |
| 요청 바디 크기 기본 | 1m (proxy-body-size로 변경) | 제한 없음에 가까움 | ALB 계층 제한 별도 |
정확한 기본값은 배포한 차트 버전에 따라 달라지므로, 실제 값은 각 프로젝트 공식 문서와 kubectl -n ingress-nginx get cm ingress-nginx-controller -o yaml로 확인하시기 바랍니다.
버전 분기표
| 변경 | 버전 | 증상 | 대응 |
|---|---|---|---|
networking.k8s.io/v1beta1 제거 | Kubernetes 1.22 | error: unable to recognize ... no matches for kind "Ingress" in version "networking.k8s.io/v1beta1" | v1으로 변환. serviceName/servicePort → service.name/service.port 구조 변경 동반 |
kubernetes.io/ingress.class 어노테이션 폐기 | 1.18 deprecated, 1.22 이후 사실상 정리 | 리소스는 생성되나 CLASS 공란·미매칭 | spec.ingressClassName 사용 |
| snippet 어노테이션 제한 | ingress-nginx 1.x | admission webhook이 configuration-snippet 거부 | ConfigMap allow-snippet-annotations 정책 검토, 가능하면 표준 어노테이션으로 대체 |
| annotation value blocklist·정규식 검증 강화 | ingress-nginx 1.x | admission webhook "validate.nginx.ingress.kubernetes.io" denied the request | 거부 사유 문구를 그대로 읽고 해당 어노테이션 값 정리 |
apply가 webhook에서 막힐 때는 우회 설정을 찾기 전에 거부 메시지 원문부터 읽는 편이 빠릅니다. 어떤 어노테이션의 어떤 토큰이 걸렸는지가 대부분 그대로 적혀 있습니다.
참고로 Ingress API는 사실상 기능 동결 상태이며 신규 라우팅 기능은 Gateway API 쪽으로 이관되는 흐름입니다. 다만 이번 편의 진단 대상은 Ingress로 한정합니다. 5xx를 Gateway API까지 포함해 넓게 보려면 K8s 5xx 에러 원인 분석: Ingress/Gateway API 7단계 디버깅 가이드를 함께 보시면 좋습니다.
그래도 안 될 때의 실패 분기
6단계를 다 돌렸는데도 해결되지 않으면 아래 세 가지를 순서대로 봅니다.
1) NetworkPolicy 차단 — 애플리케이션 네임스페이스에 default deny가 걸려 있고 ingress-nginx 네임스페이스에서 오는 트래픽을 허용하지 않은 경우입니다. Ingress는 매칭되는데 upstream 연결만 타임아웃됩니다.
kubectl get networkpolicy -AapiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-ingress-nginx
namespace: default
spec:
podSelector:
matchLabels:
app: myapp
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress-nginx2) externalTrafficPolicy: Local — 컨트롤러 Pod가 없는 노드로 들어온 요청이 드롭되어 "가끔 되고 가끔 안 되는" 간헐적 실패로 나타납니다.
kubectl -n ingress-nginx get svc ingress-nginx-controller -o jsonpath='{.spec.externalTrafficPolicy}{"\n"}'3) LB health check 경로 불일치 — 클라우드 LB가 /로 헬스체크하는데 애플리케이션은 /healthz만 200을 주면 타깃이 전부 unhealthy가 되고, 이때 증상은 헤더 없는 타임아웃입니다. 클라우드 콘솔의 타깃 그룹 상태를 직접 확인해야 합니다.
Pod 자체가 뜨지 않아 upstream이 비는 상황이라면 Pod Pending FailedScheduling 0/3 nodes 30초 진단·복구 런북이, 라우팅은 정상인데 지연이 문제라면 Kubernetes 네트워크 지연, eBPF로 근본 원인 진단하고 성능 최적화하는 완벽 가이드가 이어지는 다음 문서입니다.
다음 5편에서는 L7 진입 이후 구간인 TLS 종료와 cert-manager 인증서 발급 실패를 다룹니다.
자주 묻는 질문 (FAQ)
Q. 404가 뜨는데 Ingress 리소스는 분명히 만들었습니다. 무엇부터 볼까요?
A. kubectl get ingress -o wide의 CLASS와 ADDRESS 두 컬럼입니다. CLASS가 <none>이면 spec.ingressClassName이 없거나 IngressClass 이름과 다른 것이고, ADDRESS가 계속 공란이면 컨트롤러가 리소스를 인식하지 못한 상태입니다. 리소스가 존재하는 것과 컨트롤러가 반영한 것은 별개입니다.
Q. no healthy upstream 503과 502 Bad Gateway는 어떻게 구분하나요?
A. 503은 보낼 upstream 자체가 없는 상태(Service 이름·포트 불일치, 엔드포인트 비어 있음)이고, 502는 upstream에 연결은 했으나 응답 처리에 실패한 상태입니다. 502는 컨트롤러 로그에 upstream sent too big header, connection reset 같은 구체적 사유가 함께 남으므로 로그 원문을 먼저 확인하세요.
Q. /api로는 되는데 /api/users가 404입니다.
A. pathType: Exact일 가능성이 가장 큽니다. Prefix로 바꾸면 해결되며, 백엔드에 프리픽스를 제거해 전달해야 한다면 use-regex: "true" + path: /api(/|$)(.*) + rewrite-target: /$2 조합을 사용합니다. 이때 $1이 아니라 $2라는 점이 자주 틀리는 부분입니다.
AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.