IT/Kubernets

ingress-nginx EOL 통보를 받고 Gateway API로 옮긴 두 달의 기록

gfrog 2026. 6. 28. 18:14
SMALL

지난 4월쯤이었다. 슬랙에 누가 링크 하나를 던졌다. ingress-nginx 메인테이너들이 더 이상 best-effort 유지보수도 못한다고 공지를 올렸다는 거였다. 처음에는 "에이, 그래도 굴러는 가겠지" 했다. 우리 클러스터 8개에 다 깔려 있고, ingress 리소스만 200개가 넘는데. 근데 그 주에 보안팀에서 CVE 패치 일정을 묻는 메일이 왔고, 그제서야 정신이 들었다. 사실상 EOL 된 컨트롤러를 들고 가는 건 시간 문제일 뿐이라는 걸.

그래서 Gateway API로 가기로 했다. 5월 초부터 6월 말까지, 약 두 달 동안 삽질한 이야기를 적어둔다. 누군가는 비슷한 상황일 테니까.

처음에 안일하게 생각했던 것

Gateway API가 v1.5까지 나왔고 standard로 거의 다 올라왔다는 글을 봤다. 마이그레이션 가이드도 ingress2gateway 같은 도구가 있다고 했다. 한 달이면 끝나겠지 싶었다.

당연히 아니었다.

가장 먼저 깨달은 건 Gateway API는 단순히 Ingress의 v2가 아니라는 점이다. 리소스 모델이 아예 다르다. Ingress는 한 덩어리 YAML에 호스트, 경로, TLS, 어노테이션 다 박혀 있는 구조인데, Gateway API는 GatewayClass / Gateway / HTTPRoute / ReferenceGrant 식으로 책임이 쪼개져 있다. 인프라팀이 Gateway를 깔고, 서비스팀이 HTTPRoute만 붙이는 식. 멀티테넌시 관점에서는 훨씬 깔끔한데, 기존 어노테이션 기반으로 굴러가던 우리 환경에 그대로 매핑하기가 애매했다.

어노테이션 얘기를 좀 더 하자면, 우리는 nginx.ingress.kubernetes.io/configuration-snippet 으로 별짓을 다 해놨다. CORS, rate-limit, 특정 path 리다이렉트, 그리고 차마 글로 옮기기 부끄러운 정규식 rewrite들. ingress2gateway는 이런 어노테이션을 모르는 척한다. 변환은 해주는데 어노테이션 기반 커스텀 로직은 그냥 무시하고 HTTPRoute로 평탄화한다. 즉, 자동 변환의 결과물이 production에서 동작한다는 보장이 전혀 없다.

구현체 고르는 데서 또 한참 멈췄다

Gateway API는 API 스펙일 뿐이고, 실제 트래픽을 처리하는 건 구현체다. 후보는 Istio, Cilium, Envoy Gateway, Kong, NGINX Gateway Fabric, 그리고 cloud provider의 ALB/GLB 컨트롤러들이 있었다.

처음에는 NGINX Gateway Fabric을 우선적으로 봤다. 어쨌든 우리가 쓰던 nginx 동작 방식과 가장 비슷할 테니까. 그런데 막상 PoC를 돌려보니 기존 ingress-nginx에서 쓰던 일부 모듈(예: ModSecurity 룰)을 그대로 못 옮긴다는 걸 알았다. WAF는 따로 빼야 한다는 결론이 나왔다.

그래서 Envoy Gateway랑 Istio도 같이 PoC를 돌렸다. 한 주 반 정도 잡고 비교 매트릭스를 만들었는데, 결국 우리 환경에는 Envoy Gateway가 맞았다. 이유는 단순한데, 우리는 이미 service mesh를 안 쓰고 있고 굳이 mesh 사이드카까지 끌어들이고 싶지 않았다. 트래픽 진입 지점만 깔끔하게 바꾸고 싶었으니까. Istio ambient mode가 매력적이긴 했지만 그건 다음 분기 과제로 미뤘다.

여기서 한 가지 의외였던 건, "Gateway API는 표준이니까 구현체 바꿔도 무중단으로 갈아탈 수 있다"는 말이 절반만 맞다는 거였다. core 리소스(Gateway, HTTPRoute)는 그런데, 각 구현체가 자기 ExtensionRef로 정책을 따로 표현한다. Envoy Gateway는 BackendTrafficPolicy, SecurityPolicy 같은 CRD를 쓰고, Cilium은 또 다른 CRD를 쓴다. 결국 정책 레이어는 lock-in이 어느 정도 생긴다.

가장 아팠던 부분: 카나리 트래픽

원래는 ingress-nginx의 canary-weight 어노테이션으로 카나리 배포를 했다. 이게 진짜 편했다. 어노테이션 한 줄 바꾸면 트래픽 비율이 휙휙 바뀌니까.

Gateway API에서는 HTTPRoute의 backendRefs[].weight 로 표현한다. 개념은 거의 같은데, 실제로 옮기면서 두 번 사고가 났다.

첫 번째 사고는 weight 합계 계산을 잘못 이해해서 일어났다. ingress-nginx의 canary는 "기존 트래픽의 N%를 카나리로 보낸다"는 의미인데, Gateway API의 weight는 단순한 비율 분배다. 0과 100을 같은 의미로 쓰고 있었는데, 마이그레이션 후 한쪽 backendRef를 0으로 두니까 전체 트래픽이 사라지는 일이 생겼다. 정확히 말하면 weight 합이 0이면 구현체에 따라 동작이 달라지는데, 우리가 쓴 버전에서는 503이 나갔다. 새벽 2시 반에 페이지를 받았다.

두 번째 사고는 더 멍청했다. HTTPRoute를 새로 적용했는데, 기존 Ingress가 같은 호스트에 살아 있었다. 두 컨트롤러가 동시에 같은 호스트의 트래픽을 잡아가니까 라우팅이 랜덤하게 둘 중 하나로 갔다. 카나리 비율이 통계적으로 이상하게 찍히길래 왜 그러지 했더니, 그냥 우리가 cutover를 안 한 거였다. 마이그레이션 중에는 호스트 단위로 Ingress와 HTTPRoute 둘 다 켜놓지 말 것. 켜둘 거면 LB 앞단에서 DNS나 weighted routing으로 분리해야 한다.

DNS와 LB 앞단

이 부분은 처음부터 잘 잡고 갔어야 했는데 우리는 중반에 손을 댔다. Gateway 리소스를 만들면 구현체가 새 LoadBalancer 서비스를 만든다. 즉, 새 LB가 생긴다. 기존 ingress-nginx LB와 별개로.

DNS를 한 번에 옮기면 롤백이 거의 불가능하고, 안 옮기면 트래픽이 계속 옛 경로로 간다. 우리는 결국 Route53 weighted record로 5% → 25% → 50% → 100% 단계로 옮겼다. 각 단계마다 1-2일을 두고 에러율과 P99 레이턴시를 봤다. 결과적으로 두 번째 단계(25%)에서 한 서비스가 sticky session을 안 받는 문제가 드러나서 거기서 일주일을 더 썼다. ingress-nginx의 affinity: cookie 어노테이션이 Gateway API에서는 다른 방식(BackendTrafficPolicy의 sessionPersistence)으로 표현되는데, 우리가 처음 컨버전할 때 빠뜨렸다.

지금 와서 보면

두 달이 짧지는 않았다. 그래도 다행인 건 200개 ingress 중에 약 170개는 거의 자동 변환만으로 옮겼다는 거다. 나머지 30개가 어노테이션 헬을 짊어진 친구들이었고, 그 30개에 시간의 70%가 들어갔다.

지금 우리 클러스터는 모두 Envoy Gateway로 갈아탔고, 신규 서비스는 처음부터 HTTPRoute로 시작한다. 운영 측면에서는 좋아진 게 두 가지 있다. 첫째, 인프라팀과 서비스팀의 책임 분리가 리소스 단위로 명확해졌다. 둘째, 정책(인증, rate-limit, retry)을 CRD로 선언적으로 관리하니까 PR 리뷰가 훨씬 쉬워졌다. 어노테이션 문자열을 grep하던 시절보다는 낫다.

다만 아직 검증 중인 게 있다. 멀티 클러스터에서 같은 호스트를 공유하는 시나리오나, ReferenceGrant로 cross-namespace 라우팅을 허용했을 때의 운영 권한 정책 같은 부분. 이건 한 번 더 글로 정리할 일이 있을 것 같다.

마이그레이션을 고민하는 분이 있다면, ingress2gateway는 출발점일 뿐이라는 것만 기억하시면 된다. 어노테이션을 진지하게 점검하고, 구현체 PoC를 최소 두 개는 돌려보고, DNS 컷오버 전략을 먼저 그리고 들어가는 게 안전하다. 우리도 다시 한다면 그 순서로 할 거다.

BIG