쿠버네티스를 운영하면서 nginx.ingress.kubernetes.io/rewrite-target 한 줄로 문제를 해결해 본 경험은 누구에게나 있을 것이다. 그런데 그 컨트롤러가 2026년 3월 24일부로 아카이브됐다. 저장소는 읽기 전용으로 잠겼고, 새 CVE가 나와도 패치는 없다. 쿠버네티스 Steering Committee와 Security Response Committee의 공식 성명에 따르면 Datadog 내부 조사 기준으로 클라우드 네이티브 환경의 약 50%가 이 컨트롤러에 의존하고 있으니, 남의 일이 아닐 확률이 절반이다. 후속 프로젝트로 기대를 모았던 InGate마저 성숙 단계에 도달하지 못하고 함께 은퇴했기 때문에, 남은 선택지는 사실상 Gateway API 하나로 좁혀졌다. 이번 포스팅에서는 Gateway API 마이그레이션을 CRD 설치부터 컨트롤러 선택, ingress2gateway를 이용한 리소스 변환, 무중단 전환 순서까지 정리하고자 한다.
절반의 클러스터가 지금 시한폭탄을 안고 있다

ingress-nginx는 2026년 3월 24일 GitHub 저장소가 아카이브되며 EOL에 도달했다. 이후로는 신규 릴리스도, 버그 픽스도, 보안 취약점 패치도 제공되지 않는다. 기존에 떠 있는 파드는 계속 동작하지만, 그것이 오히려 문제다. 장애가 나지 않으니 아무도 눈치채지 못한 채 패치 없는 게이트웨이가 인터넷에 노출된 상태로 방치된다.
쿠버네티스 SIG Network와 Security Response Committee는 2025년 11월 은퇴 공지에서 원인을 기술 부채와 유지보수 인력 부족으로 짚었다. 공지문의 표현을 그대로 옮기면 “Yesterday’s flexibility has become today’s insurmountable technical debt”다. snippet annotation처럼 임의의 NGINX 설정을 주입할 수 있던 유연함이 IngressNightmare(CVE-2025-1974) 같은 사고로 되돌아왔고, 정작 그 코드베이스를 돌보는 인원은 “one or two people doing development work, on their own time, after work hours and on weekends” 수준이었다.
# 클러스터에 ingress-nginx가 살아 있는지 확인
kubectl get pods --all-namespaces \
--selector app.kubernetes.io/name=ingress-nginx
# 어떤 Ingress가 nginx 클래스에 묶여 있는지 집계
# 구형 클러스터는 ingressClassName 대신 annotation을 쓰므로 둘 다 뽑는다
kubectl get ingress -A -o custom-columns=\
'NS:.metadata.namespace,NAME:.metadata.name,'\
'CLASS:.spec.ingressClassName,'\
'LEGACY:.metadata.annotations.kubernetes\.io/ingress\.class'ShellScript첫 명령어는 쿠버네티스 공식 성명에서 직접 권고한 확인 절차다. 두 번째 명령어에서 LEGACY 컬럼을 따로 뽑는 이유가 있다. spec.ingressClassName 필드가 도입되기 전에 만들어진 Ingress는 kubernetes.io/ingress.class annotation으로 컨트롤러를 지정하는데, 이쪽만 보면 대상 목록에서 조용히 빠진다. 첫 명령어 출력이 비어 있다면 Traefik이나 HAProxy 같은 서드파티 컨트롤러를 쓰고 있다는 뜻이고, 이번 은퇴의 직접 영향권은 아니다.
한 가지 오해를 정리하면, 은퇴한 것은 kubernetes/ingress-nginx 컨트롤러 프로젝트이지 Ingress API 자체가 아니다. Ingress 리소스 스펙은 여전히 쿠버네티스 코어에 남아 있다. 다만 가장 많이 쓰이던 구현체가 사라졌으므로, 실질적으로는 대체 컨트롤러를 고르거나 Gateway API 마이그레이션을 진행하는 두 갈래 중 하나를 택해야 한다.
Ingress와 Gateway API, 뭐가 그렇게 다른가

가장 큰 차이는 annotation에 흩어져 있던 설정이 표준 필드로 들어왔다는 점과, 리소스가 역할별로 쪼개졌다는 점이다. Ingress는 인프라 설정과 애플리케이션 라우팅이 한 오브젝트에 뒤엉켜 있었다. Gateway API는 이를 GatewayClass, Gateway, HTTPRoute 세 계층으로 분리해 인프라팀과 애플리케이션팀의 권한 경계를 API 수준에서 그어 놓았다.
| 항목 | Ingress | Gateway API |
|---|---|---|
| 리소스 구성 | Ingress + IngressClass | GatewayClass + Gateway + HTTPRoute/GRPCRoute/TCPRoute/UDPRoute |
| 벤더별 설정 | annotation (비표준, 컨트롤러마다 상이) | 표준 스펙 필드 + Policy 리소스 |
| 역할 분리 | 없음 (한 오브젝트에 전부) | 인프라 담당자가 Gateway, 앱 담당자가 Route 소유 |
| 트래픽 분할 | 별도 canary Ingress + annotation | backendRefs[].weight 표준 필드 |
| 헤더 기반 라우팅 | 컨트롤러 구현에 의존 | matches[].headers 표준 지원 |
| L4 프로토콜 | 사실상 미지원 | TCPRoute, UDPRoute (v1.6.0에서 GA) |
| 크로스 네임스페이스 | 불가 | ReferenceGrant로 명시적 허용 |
| 최신 버전 | 코어 API 고정 | v1.6.1 (Standard Channel) |
표에서 실무적으로 가장 체감되는 항목은 역할 분리와 트래픽 분할이다. 기존에는 카나리 배포를 하려면 동일한 호스트에 nginx.ingress.kubernetes.io/canary: "true"가 붙은 두 번째 Ingress를 만들어야 했다. Gateway API에서는 HTTPRoute 하나 안에 weight를 여러 개 나열하면 끝난다. L4 라우팅이 필요해 별도 LoadBalancer 서비스를 만들던 패턴도 TCPRoute로 흡수된다.
역할 분리는 멀티 테넌트 클러스터에서 특히 효과가 크다. 플랫폼팀이 Gateway와 TLS 인증서, 리스너 포트를 소유하고 allowedRoutes로 어떤 네임스페이스가 붙을 수 있는지 통제한다. 서비스팀은 자기 네임스페이스의 HTTPRoute만 건드린다. 예전처럼 Ingress 하나 잘못 고쳐서 다른 팀 라우팅이 깨지는 사고가 구조적으로 막힌다.

CRD부터 깔고 시작하는 Gateway API 마이그레이션

Gateway API는 쿠버네티스 코어에 내장돼 있지 않고 CRD로 설치한다. 여기서 첫 번째 함정이 나온다. CRD를 먼저 깔고 컨트롤러를 나중에 얹는 순서가 항상 맞지는 않는다. 컨트롤러마다 자기가 검증한 Gateway API 버전을 핀으로 박아두기 때문에, 어느 쪽이 CRD를 소유할지 먼저 정하지 않으면 버전 충돌이 난다.
# Standard Channel — GA 리소스만 포함 (컨트롤러가 CRD를 번들하지 않을 때)
kubectl apply --server-side -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.1/standard-install.yaml
# Experimental Channel — TLSRoute, ListenerSet 등 알파 기능 포함
kubectl apply --server-side -f \
https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.1/experimental-install.yaml
# 현재 클러스터에 깔린 CRD와 채널 확인
kubectl get crd -l gateway.networking.k8s.io/bundle-version \
-o custom-columns='NAME:.metadata.name,'\
'VERSION:.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version,'\
'CHANNEL:.metadata.annotations.gateway\.networking\.k8s\.io/channel'ShellScript--server-side 플래그가 핵심이다. Gateway API CRD는 스키마가 커서 클라이언트 사이드 apply를 쓰면 metadata.annotations 크기 제한(262144 bytes)에 걸려 실패한다. 세 번째 명령어는 이미 깔린 CRD의 번들 버전과 채널을 한눈에 보여준다. 컨트롤러를 설치하기 전에 이 출력을 확인해두면, 나중에 버전이 덮어써졌는지 되짚어 볼 수 있다.
채널 선택도 미리 결정해야 한다. 현재 최신 정식 릴리스는 v1.6.1이고 TCPRoute와 UDPRoute는 v1.6.0에서 GA로 승격되며 Standard Channel에 편입됐다. 반면 TLSRoute는 여전히 Experimental Channel 전용이다. TLS passthrough가 필요한 서비스가 있다면 처음부터 Experimental로 가야 하고, 나중에 갈아타려면 CRD를 통째로 교체해야 한다.
Gateway API 마이그레이션의 첫 갈림길, 컨트롤러 선택

결론부터 말하면 이미 쓰고 있는 스택에서 출발하는 것이 가장 안전하다. Cilium을 CNI로 쓴다면 Cilium, NGINX 운영 자산을 살리려면 NGINX Gateway Fabric, 게이트웨이에서 JWT 검증이나 레이트 리밋까지 처리하려면 Envoy Gateway가 기본값이다. 여기서 이미 답이 나왔다면 아래 표는 건너뛰어도 된다. 애매하다면 서비스 메시를 쓰는지, NGINX 설정 자산이 얼마나 쌓여 있는지를 먼저 따져보는 편이 빠르다.
| 컨트롤러 | 데이터 플레인 | 최근 conformance 리포트 | 이럴 때 고른다 |
|---|---|---|---|
| Envoy Gateway | Envoy | v1.4.0 | 벤더 중립, mTLS·JWT 검증·레이트 리밋이 필요할 때 |
| NGINX Gateway Fabric | NGINX | v1.6.1 | NGINX 운영 노하우를 그대로 살리고 싶을 때 |
| Cilium | eBPF (사이드카리스) | v1.6.1 | 이미 Cilium을 CNI로 쓰고 있을 때 |
| kgateway | Envoy | v1.6.1 | API 게이트웨이 기능까지 한 컨트롤러로 묶고 싶을 때 |
| Traefik Proxy | Traefik | v1.5.1 | 기존 Traefik IngressRoute 자산이 있을 때 |
| Istio | Envoy | v1.5.1 | 이미 서비스 메시를 운영 중일 때 |
| HAProxy Ingress | HAProxy | v1.5.1 | 레이턴시 민감한 트래픽이 많을 때 |
이 표를 읽을 때 주의할 점이 있다. 세 번째 컬럼은 컨트롤러가 지원하는 최대 버전이 아니라 공식 구현체 목록에 마지막으로 제출된 conformance 리포트 버전이다. 리포트를 언제 제출했느냐에 따라 숫자가 달라지므로 지원 범위와 그대로 일치하지 않는다. 실제로 Envoy Gateway는 리포트가 v1.4.0이지만 v1.8.x는 Gateway API v1.5.1 experimental 채널을 요구한다. 컨트롤러 버전에 따라서도 달라져서, Cilium의 v1.6.1 conformance는 Cilium 1.20.0부터다. 최종 확인은 각 벤더의 호환성 표에서 해야 한다.
# Envoy Gateway — 차트가 Gateway API CRD를 함께 설치한다 (crds.enabled 기본 true)
# 별도로 CRD를 깔았다면 --set crds.gatewayAPI.enabled=false 로 충돌을 피한다
helm install eg oci://docker.io/envoyproxy/gateway-helm \
--version v1.8.3 -n envoy-gateway-system --create-namespace
kubectl wait --timeout=5m -n envoy-gateway-system \
deployment/envoy-gateway --for=condition=Available
# Envoy Gateway는 GatewayClass를 자동 생성하지 않으므로 직접 만든다
kubectl apply -f - <<'EOF'
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: eg
spec:
controllerName: gateway.envoyproxy.io/gatewayclass-controller
EOFShellScriptEnvoy Gateway는 Helm 차트가 Gateway API CRD까지 통째로 들고 온다. 앞 절에서 CRD를 미리 깔았다면 Helm 소유권 충돌이 나거나 차트가 번들한 버전으로 덮어써지므로, 둘 중 하나는 반드시 꺼야 한다. 그리고 helm install만으로는 GatewayClass가 만들어지지 않는다. kubectl get gatewayclass가 비어 있는 것이 정상이며, 위처럼 controllerName을 지정해 직접 생성해야 Gateway가 처리되기 시작한다.
# NGINX Gateway Fabric — NGF가 핀한 버전의 CRD를 먼저 깐다
kubectl kustomize \
"https://github.com/nginx/nginx-gateway-fabric/config/crd/gateway-api/standard?ref=v2.6.7" \
| kubectl apply -f -
helm install ngf oci://ghcr.io/nginx/charts/nginx-gateway-fabric \
--version 2.6.7 --create-namespace -n nginx-gateway
# NGF는 GatewayClass 'nginx'를 자동 생성한다
kubectl get gatewayclassShellScriptNGINX Gateway Fabric은 정반대 방식이다. 공식 문서가 “The Gateway API resources from the standard channel must be installed before deploying NGINX Gateway Fabric”이라고 명시하며, ref=v2.6.7처럼 NGF 버전에 대응하는 CRD를 kustomize로 먼저 깔라고 요구한다. 커뮤니티 최신 릴리스인 v1.6.1 CRD를 깔아두고 NGF를 얹으면 벤더가 검증하지 않은 조합이 된다. 대신 NGF는 GatewayClass nginx를 차트가 함께 만들어주므로, 이 이름을 그대로 Gateway의 gatewayClassName에 넣으면 된다.
정리하면 CRD 소유권은 컨트롤러가 정한다. Envoy Gateway 계열은 차트에 맡기고, NGF 계열은 벤더가 지정한 CRD를 먼저 깐다. 이 순서를 뒤집으면 Gateway가 Accepted 상태로 넘어가지 않는데, 원인이 CRD 버전 스큐라는 것을 알아내는 데 반나절이 날아간다.
ingress2gateway 1.0, 손으로 옮기지 말자
기존 Ingress 리소스를 HTTPRoute로 옮기는 작업은 SIG Network가 만든 ingress2gateway에 맡긴다. 2026년 3월 20일 1.0이 릴리스되며 30개가 넘는 ingress-nginx annotation 변환과 컨트롤러 레벨 통합 테스트가 들어갔다. 생성되는 리소스는 Gateway API v1.5.0 스펙 기준이다.
# 설치 (택 1)
go install github.com/kubernetes-sigs/ingress2gateway@v1.0.0
brew install ingress2gateway
# 현재 kubeconfig 컨텍스트에서 전체 네임스페이스 변환
ingress2gateway print --providers=ingress-nginx --all-namespaces
# 특정 네임스페이스만 변환해서 파일로 저장
ingress2gateway print --providers=ingress-nginx -n shop > shop-gateway.yaml
# 클러스터 접속 없이 매니페스트 파일만 변환
ingress2gateway print --providers=ingress-nginx \
--input-file=./manifests/ingress.yaml -o yaml
# 컨트롤러 확장 리소스까지 함께 생성 (1.0 신규)
ingress2gateway print --providers=ingress-nginx --emitter=envoy-gatewayShellScript--providers는 유일한 필수 플래그이고 apisix, cilium, ingress-nginx, istio, gce, kong, nginx, openapi, traefik을 지원한다. --input-file은 클러스터에 붙지 않고 로컬 YAML만 변환하므로 GitOps 저장소를 통째로 돌려볼 때 유용하다. 1.0에서 추가된 --emitter가 특히 쓸모 있는데, standard(기본값) 외에 agentgateway, airlock-microgateway, envoy-gateway, gce, kgateway를 지정하면 표준 스펙에 없는 설정까지 해당 컨트롤러의 확장 리소스로 뽑아준다. 실행 결과는 표준 출력으로만 나오고 클러스터에 반영되지 않으니 검토 없이 apply되는 사고는 나지 않는다.
실제 변환이 어떤 모양인지 보면 감이 온다. 아래는 흔한 형태의 Ingress 하나가 어떻게 바뀌는지 나타낸 예시로, 도구 출력에 allowedRoutes 같은 운영 설정을 덧붙여 정리한 형태다.
# Before — ingress-nginx 시절
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: shop-api
port:
number: 8080YAML# After — Gateway API
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shop-gateway
spec:
gatewayClassName: nginx # kubectl get gatewayclass 결과와 일치해야 한다
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: Same # 같은 네임스페이스의 Route만 허용
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: shop-route
spec:
parentRefs:
- name: shop-gateway # 어느 Gateway에 붙을지 명시
hostnames:
- "shop.example.com"
rules:
- matches:
- path:
type: PathPrefix
value: /api
filters:
- type: URLRewrite # rewrite-target annotation의 대체
urlRewrite:
path:
type: ReplacePrefixMatch
replacePrefixMatch: /
backendRefs:
- name: shop-api
port: 8080YAMLannotation 한 줄이 filters 블록으로 승격된 것이 핵심 변화다. YAML 줄 수는 늘었지만 스키마 검증을 받는 표준 필드이므로 오타가 apply 시점에 걸린다. 예전에는 annotation 이름을 잘못 적어도 조용히 무시된 뒤 프로덕션에서야 라우팅이 안 되는 것을 알아챘다. allowedRoutes.namespaces.from을 Same으로 두면 다른 네임스페이스가 이 Gateway에 무단으로 붙는 것을 막을 수 있고, Selector로 라벨 기반 허용도 가능하다.
손으로 옮겨야 하는 annotation은 따로 있다

ingress2gateway가 30개 이상을 변환하지만 전부는 아니다. 구체적으로는 configuration-snippet, server-snippet, canary-by-cookie, canary-by-header-pattern, custom-headers, proxy-ssl-protocols 계열이 자동 변환 대상에서 빠진다. 변환되는 것과 경고만 뱉고 넘어가는 것을 미리 구분해두면, 변환 결과를 리뷰하는 시간이 크게 줄어든다.
| ingress-nginx annotation | Gateway API 대응 |
|---|---|
rewrite-target | URLRewrite 필터 (ReplaceFullPath / ReplacePrefixMatch) |
canary + canary-weight | backendRefs[].weight |
canary-by-header | matches[].headers (HTTPHeaderMatch) |
permanent-redirect | HTTPRequestRedirect 필터 (301) |
temporal-redirect / app-root | HTTPRequestRedirect 필터 (302) |
upstream-vhost | HTTPRequestHeaderModifier로 Host 헤더 설정 |
enable-cors 계열 | CORS 필터 (v1.6.0에서 Standard Channel 편입) |
use-regex | PathMatchRegularExpression |
backend-protocol: GRPC | GRPCRoute |
backend-protocol: HTTPS | BackendTLSPolicy |
proxy-ssl-verify / proxy-ssl-secret | BackendTLSPolicy |
proxy-read-timeout / proxy-send-timeout | rules[].timeouts.request / timeouts.backendRequest |
proxy-connect-timeout | 표준 필드 없음 (컨트롤러 확장) |
ssl-passthrough | TLSRoute (Experimental Channel 필요) |
whitelist-source-range / denylist-source-range | 표준 필드 없음 (--emitter로 확장 리소스 생성) |
canary-by-cookie | 자동 변환 미지원 |
canary-by-header-pattern | 자동 변환 미지원 |
custom-headers | 자동 변환 미지원 |
proxy-ssl-protocols / proxy-ssl-verify-depth | 자동 변환 미지원 |
configuration-snippet / server-snippet | 대응 없음 (컨트롤러별 확장 필요) |
표 하단의 미지원 항목이 실제 마이그레이션 일정을 좌우한다. 특히 snippet 계열은 애초에 이번 은퇴를 부른 보안 이슈의 진원지였기 때문에 Gateway API 표준에 대응 개념이 없다. Envoy Gateway의 EnvoyPatchPolicy나 NGINX Gateway Fabric의 SnippetsFilter처럼 컨트롤러 확장 리소스로 옮기거나, 아예 애플리케이션 레벨로 로직을 끌어올려야 한다.
# 자동 변환이 불가능한 annotation을 쓰는 Ingress부터 찾아낸다
kubectl get ingress -A -o json | jq -r '
.items[]
| select((.metadata.annotations // {}) | keys
| any(test("snippet|custom-headers|canary-by-cookie|canary-by-header-pattern|proxy-ssl-protocols|proxy-ssl-verify-depth")))
| "\(.metadata.namespace)/\(.metadata.name)"'ShellScriptkeys | any(test(...))로 감싼 이유가 있다. keys[]를 그대로 쓰면 매칭되는 annotation이 두 개인 Ingress가 두 번 출력되므로 sort -u에 의존해야 한다. any는 불리언 하나만 돌려주므로 중복이 애초에 생기지 않는다. Gateway API 마이그레이션 계획을 세울 때 이 목록이 곧 수작업 대상이자 일정 산정의 기준이 된다. 필자 경험상 전체 Ingress의 10~20% 정도가 여기에 걸리는데, ingress2gateway가 자동 변환하는 annotation이 30개 남짓임을 감안하면 이 비율이 30%를 넘는 클러스터는 한 번에 넘어가기보다 서비스 단위로 쪼개는 편이 안전하다.
무중단으로 갈아타는 순서

Gateway API 마이그레이션은 스위치를 한 번에 내리는 작업이 아니라 병행 운영 구간을 두고 트래픽을 옮기는 작업이다. 순서는 병행 배포, Route 배포, 내부 검증, DNS 가중치 전환, 철거 다섯 단계다. Ingress와 Gateway는 서로 다른 로드밸런서를 쓰기 때문에 두 경로를 동시에 살려둘 수 있고, 이것이 무중단 전환의 전제가 된다.
- 병행 배포 — 기존 ingress-nginx는 그대로 두고 새 컨트롤러와 Gateway를 별도 네임스페이스에 올린다. Gateway API 마이그레이션이 사용자에게 노출되기 시작하는 시점은 4단계부터이므로, 여기까지는 마음 편히 진행해도 된다.
- Route 배포 — ingress2gateway 출력물을 리뷰해 HTTPRoute를 apply한다. 기존 Ingress는 아직 살아 있으므로 두 라우팅이 공존한다.
- 내부 검증 — 새 로드밸런서 주소로 Host 헤더를 강제해 응답을 비교한다. DNS를 건드리기 전이라 실패해도 사용자 영향이 없다.
- DNS 가중치 전환 — Route 53 가중치 라우팅이나 ExternalDNS로 새 로드밸런서 비중을 5% → 25% → 100%로 올린다. 문제가 생기면 가중치만 되돌린다.
- 철거 — 최소 한 번의 트래픽 피크를 넘긴 뒤 ingress-nginx Deployment와 Ingress 리소스를 삭제한다.
# 3단계 검증 — DNS 변경 없이 새 Gateway로 직접 요청
GW_IP=$(kubectl get gateway shop-gateway \
-o jsonpath='{.status.addresses[0].value}')
curl -sS -o /dev/null -w '%{http_code}\n' \
-H 'Host: shop.example.com' "http://${GW_IP}/api/health"
# Gateway와 HTTPRoute가 정상 수락됐는지 상태 확인
kubectl get gateway shop-gateway \
-o jsonpath='{.status.conditions[*].type}{"\n"}{.status.conditions[*].status}{"\n"}'
kubectl describe httproute shop-route | tail -20ShellScriptstatus.addresses[0].value에서 Gateway가 할당받은 실제 주소를 뽑아 Host 헤더를 붙여 호출하면 DNS를 바꾸지 않고도 라우팅을 검증할 수 있다. 공식 Troubleshooting and Status 문서는 Accepted를 “semantically and syntactically valid… has been accepted by a controller”, Programmed를 설정이 데이터 플레인까지 전달된 상태로 정의한다. 둘 다 True여야 정상이다. HTTPRoute 쪽은 describe 출력의 Parents 섹션에서 ResolvedRefs 조건을 확인한다. 백엔드 서비스 이름이나 포트가 틀리면 여기서 False로 잡힌다.
4단계에서 자주 빠뜨리는 것이 cert-manager와 ExternalDNS 설정이다. cert-manager의 HTTP-01 챌린지는 기본적으로 Ingress를 생성하므로, Gateway API 환경에서는 ACME HTTP-01 문서에 나온 gatewayHTTPRoute 솔버로 바꿔야 한다. ExternalDNS도 --source=gateway-httproute를 추가해야 HTTPRoute의 hostname을 읽어 레코드를 만든다. 이 둘을 놓치면 전환 직후 인증서 갱신이 조용히 실패한다. cert-manager는 만료 30일 전에 갱신을 시도하므로 90일짜리 인증서 기준으로 대략 두 달 뒤에야 장애로 드러난다.
Gateway API 마이그레이션 전에 알았으면 좋았을 것들

발목을 잡는 것은 라우팅 규칙 자체가 아니라 주변 도구다. 모니터링 대시보드, 인증서 발급, WAF, 접근 로그 포맷이 전부 ingress-nginx를 전제로 짜여 있기 때문이다. 라우팅은 하루면 옮기지만 이 주변부가 2~3주를 잡아먹는다. Gateway API 마이그레이션 일정을 산정할 때 이 비율을 뒤집어 잡으면 반드시 늦어진다.
메트릭 이름부터 완전히 달라진다. ingress-nginx의 nginx_ingress_controller_requests에 걸어둔 알럿과 Grafana 대시보드는 그대로 죽는다. Envoy 기반 컨트롤러는 envoy_http_downstream_rq_total 계열을 노출하므로 쿼리를 새로 짜야 하고, 라벨 카디널리티 구조도 다르다. 전환 전에 대시보드를 미리 복제해 두 지표를 나란히 띄워 놓으면 트래픽이 실제로 옮겨가는 과정을 눈으로 확인할 수 있다.
접근 로그 포맷도 컨트롤러마다 다르다. ingress-nginx의 기본 로그 포맷에 맞춰 만든 Fluent Bit 파서나 Loki 쿼리는 재작성 대상이다. 여기서 예상보다 시간이 걸리는 이유는 로그 필드 이름이 바뀌면 SLO 계산 쿼리, 장애 대응 런북, 보안팀 감사 로그 파이프라인이 연쇄적으로 영향을 받기 때문이다.
마지막으로 IP 화이트리스트다. whitelist-source-range annotation은 Gateway API 표준에 대응 필터가 없어서, --emitter로 컨트롤러 확장 리소스를 뽑거나 직접 작성해야 한다. Envoy Gateway는 SecurityPolicy, Cilium은 CiliumNetworkPolicy와 조합하는 식이다. 사내 어드민 페이지처럼 IP 제한에 의존하는 엔드포인트가 있다면, 이 부분만큼은 전환 리허설을 반드시 거치는 것이 좋다.
FAQ
전환을 준비하면서 가장 자주 나온 질문 다섯 개를 정리했다.
ingress-nginx를 그냥 계속 써도 되나?
동작은 계속한다. 다만 새로 발견되는 CVE에 대한 패치가 제공되지 않으므로 시간이 지날수록 위험이 누적된다. 쿠버네티스 Steering Committee는 공식 성명에서 은퇴 후 잔류를 두고 “leaves you and your users vulnerable to attack”이라고 표현했다. 즉시 전환이 어렵다면 상용 장기 지원(LTS) 벤더를 붙이는 방법이 임시 대안이 되지만, 어디까지나 마이그레이션 일정을 사는 조치로 보는 편이 맞다.
Ingress API 자체가 없어지는 건가?
아니다. Ingress 리소스 스펙은 쿠버네티스 코어 API에 그대로 남아 있고 제거 계획도 없다. 은퇴한 것은 kubernetes/ingress-nginx라는 특정 컨트롤러 구현체다. Traefik, HAProxy, Kong, AWS Load Balancer Controller 같은 다른 Ingress 컨트롤러를 쓰고 있다면 이번 은퇴의 직접 영향은 없다. 다만 신규 기능 개발은 대부분 Gateway API 쪽으로 이동하고 있으므로 중장기적으로는 Gateway API 마이그레이션 검토가 필요하다.
ingress2gateway 변환 결과를 그대로 apply해도 되나?
권장하지 않는다. 이 도구는 표준 출력으로 매니페스트를 뽑아줄 뿐 클러스터에 반영하지 않으며, 이는 의도된 설계다. 특히 configuration-snippet, canary-by-cookie, custom-headers처럼 자동 변환이 불가능한 annotation은 경고만 남기고 넘어가므로, 변환 결과에 그 로직이 빠져 있다. 반드시 diff를 리뷰한 뒤 GitOps 저장소에 커밋하는 흐름으로 가져가는 것이 안전하다.
Gateway API 마이그레이션 중에 Ingress와 Gateway를 동시에 운영할 수 있나?
가능하고, 오히려 권장되는 방식이다. 둘은 서로 다른 로드밸런서와 데이터 플레인을 사용하므로 같은 클러스터, 같은 서비스에 동시에 붙어도 충돌하지 않는다. 실무에서는 새 Gateway를 먼저 올려 Host 헤더로 내부 검증을 마친 뒤, DNS 가중치를 단계적으로 올려 트래픽을 옮기고, 마지막에 ingress-nginx를 철거하는 순서를 쓴다. 롤백은 DNS 가중치를 되돌리는 것으로 끝난다.
Gateway API 마이그레이션에서 CRD는 컨트롤러 설치 전에 깔아야 하나?
컨트롤러에 따라 다르다. Envoy Gateway는 Helm 차트가 CRD를 함께 설치하므로(crds.enabled 기본값 true) 미리 깔면 소유권 충돌이나 버전 덮어쓰기가 발생한다. 반대로 NGINX Gateway Fabric은 공식 문서가 설치 전에 NGF 버전에 대응하는 CRD를 kustomize로 먼저 깔라고 요구한다. 어느 쪽이든 벤더 문서의 호환 버전표를 먼저 확인하고, CRD 소유권을 한 곳으로 몰아두는 것이 원칙이다.
마치며
지금까지 ingress-nginx 은퇴에 따른 Gateway API 마이그레이션을 CRD 설치부터 컨트롤러 선택, 리소스 변환, 무중단 전환 순서까지 정리해 보았다.
돌아보면 ingress-nginx의 은퇴 소식을 처음 들었을 때 가장 먼저 든 생각은 “일이 늘었다”였다. 그런데 실제로 변환 결과를 열어보니 그동안 annotation 스무 개를 덕지덕지 붙여 놓고 왜 붙였는지 아무도 기억하지 못하는 Ingress가 여러 개 나왔다. 절반은 이미 필요 없는 설정이었다. 작업의 체감상 절반은 새 API를 배우는 시간이 아니라, 몇 년간 쌓인 설정 부채를 하나씩 확인하고 지우는 시간이었다.
한 가지 조언을 남기자면 라우팅 규칙을 옮기는 일정보다 주변 도구를 옮기는 일정을 넉넉하게 잡는 편이 좋다. HTTPRoute 작성은 반나절이면 익숙해지지만, cert-manager 솔버 설정과 Grafana 대시보드, 로그 파서를 정리하는 데는 그보다 훨씬 오래 걸린다. CRD 버전 스큐로 반나절을 태운 것도 결국 벤더 문서를 먼저 안 읽어서 생긴 일이었다. 어차피 미룰 수 없는 일이라면 트래픽이 한가한 분기에 먼저 병행 배포부터 해두는 것을 권한다.
