Gateway API v1.5 ListenerSet으로 멀티팀 Gateway 정리하기
기존 Gateway 모델의 한계
올해 2월에 Gateway API v1.5가 나왔고, ListenerSet이 드디어 Standard 채널로 올라왔다. 우리 팀에서는 v1.4 시절부터 Experimental로 깔짝거리며 써봤는데, 이제 GA니까 본격적으로 도입했다. 막상 옮겨보니 단순한 기능 추가가 아니라 멀티팀 환경에서 Gateway 관리 모델 자체가 바뀌는 변화였다.
이 글은 ListenerSet이 뭔지, 왜 필요한지, 그리고 기존 Gateway에서 어떻게 갈아끼는지 정리한 가이드다. Istio나 Envoy Gateway 같은 구현체에 따라 세부 동작이 다르긴 한데, 기본 개념은 동일하다.
Gateway API에서 Gateway 리소스 하나는 보통 플랫폼 팀이 소유한다. Listener를 여러 개 박아두고, 각 팀의 HTTPRoute가 거기에 attach되는 구조다.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: gateway-system
spec:
gatewayClassName: istio
listeners:
- name: api-team-https
port: 443
protocol: HTTPS
hostname: "*.api.company.com"
tls: { ... }
- name: web-team-https
port: 443
protocol: HTTPS
hostname: "*.web.company.com"
tls: { ... }
- name: ml-team-https
port: 443
protocol: HTTPS
hostname: "*.ml.company.com"
tls: { ... }
이게 팀 3개일 땐 괜찮은데, 10개 20개 되면 문제가 생긴다. 팀마다 호스트네임 추가하고 싶을 때마다 플랫폼 팀에 PR을 날려야 한다. 한 번은 새 listener 한 줄 추가하는 PR이 일주일째 리뷰 대기 중인 적도 있었다. 우리 팀 잘못은 아니었는데도.
게다가 Gateway 한 리소스에 listener가 너무 많이 박히면 Envoy 설정 reload가 무거워진다. 한 팀이 인증서 갱신해도 전체 Listener가 영향을 받는다.
ListenerSet이 풀어주는 문제
ListenerSet은 listener 정의를 Gateway에서 떼어내서 별도 리소스로 만든다. 각 팀이 자기 namespace에서 ListenerSet을 만들고, 그게 플랫폼 팀의 Gateway에 merge되는 구조다.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: shared-gateway
namespace: gateway-system
spec:
gatewayClassName: istio
listeners:
- name: default-https
port: 443
protocol: HTTPS
hostname: "company.com"
tls: { ... }
allowedListeners:
namespaces:
from: Selector
selector:
matchLabels:
gateway-merging: enabled
apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
name: api-team-listeners
namespace: api-team
spec:
parentRef:
name: shared-gateway
namespace: gateway-system
kind: Gateway
listeners:
- name: api-https
port: 443
protocol: HTTPS
hostname: "*.api.company.com"
tls: { ... }
핵심은 parentRef로 어느 Gateway에 merge될지 가리키고, Gateway 쪽에서는 allowedListeners로 어떤 namespace의 ListenerSet을 받을지 통제한다는 것. 권한 모델이 ReferenceGrant 비슷하게 깔끔해진다.
마이그레이션 절차
기존 Gateway에서 listener 항목을 ListenerSet으로 옮기는 순서는 이렇게 잡았다.
1. 플랫폼 팀이 Gateway에 allowedListeners 추가
먼저 기존 Gateway에 allowedListeners 블록을 추가한다. 이때 selector를 좁게 시작하는 게 좋다. 처음부터 from: All 같은 거 쓰면 의도치 않은 ListenerSet이 붙을 수 있다.
spec:
allowedListeners:
namespaces:
from: Selector
selector:
matchExpressions:
- key: gateway-merging
operator: In
values: ["enabled"]
라벨 기반으로 통제하니까 새 팀이 들어올 때 namespace 라벨만 붙이면 된다.
2. 팀 하나씩 ListenerSet으로 이관
여기서 중요한 건, 한 번에 다 옮기지 말 것. listener 하나씩 옮기면서 트래픽 확인하는 게 안전하다. 우리는 가장 트래픽 적은 팀부터 시작했다.
apiVersion: gateway.networking.k8s.io/v1
kind: ListenerSet
metadata:
name: ml-team-listeners
namespace: ml-team
labels:
gateway-merging: enabled
spec:
parentRef:
name: shared-gateway
namespace: gateway-system
listeners:
- name: ml-inference-https
port: 443
protocol: HTTPS
hostname: "*.ml.company.com"
tls:
mode: Terminate
certificateRefs:
- name: ml-team-cert
ListenerSet 적용 후 kubectl get gateway shared-gateway -o jsonpath='{.status}'로 merged 상태 확인. 우리 환경에서는 status에 MergedListeners condition이 추가됐다.
3. 기존 Gateway에서 해당 listener 제거
ListenerSet 쪽이 정상 작동하는 게 확인되면 원본 Gateway의 listeners 배열에서 그 항목을 뺀다. 이때 traffic drain은 일어나지 않는데, 어차피 같은 hostname/port로 listener가 살아있기 때문이다.
4. HTTPRoute의 parentRef 조정 (필요 시)
기존 HTTPRoute가 parentRef.sectionName으로 특정 listener를 가리키고 있다면, ListenerSet으로 옮긴 후에도 같은 이름을 유지하면 변경 없이 작동한다. listener name이 바뀌었다면 HTTPRoute도 수정해야 한다.
운영하면서 주의할 점
ListenerSet 도입하고 한 달쯤 굴려보면서 발견한 것들.
Port 충돌 처리 — ListenerSet 두 개가 같은 port에 다른 protocol을 선언하면 reject된다. status에 Conflicted: True로 표시되는데, 이거 처음에 한 번 당했다. 두 팀이 동시에 8080 HTTP를 선언했는데, 한쪽은 Plain HTTP, 다른 쪽은 HTTPS였다. 둘 다 attach가 안 됐고, 디버깅에 시간 좀 썼다.
Certificate 관리 분산의 trade-off — 각 팀이 자기 ListenerSet에 cert를 박을 수 있는 게 장점이자 단점이다. cert-manager로 자동 갱신 도는 거 좋은데, 팀마다 다른 ClusterIssuer 쓰면서 카오스가 시작될 수 있다. 우리는 ClusterIssuer는 공유하고 Certificate 리소스만 팀 namespace에 두는 정책으로 합의했다.
Gateway status 폭증 — Gateway의 status에 모든 merged listener 정보가 누적된다. 50개 넘는 ListenerSet이 붙는 경우 etcd 부담이 늘어날 수 있다는 얘기를 KubeCon EU에서 들었는데, 우리는 아직 그 규모는 아니라 체감 못 했다.
CORS Filter도 같이 챙기기
v1.5에서 HTTPRoute CORS Filter도 Standard로 올라왔다. 이건 별개 기능인데, 기존에 Envoy filter나 sidecar로 CORS 처리하던 걸 HTTPRoute에서 선언적으로 가능해졌다.
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-cors
spec:
parentRefs:
- name: shared-gateway
namespace: gateway-system
rules:
- filters:
- type: CORS
cors:
allowOrigins:
- "https://app.company.com"
allowMethods: [GET, POST, PUT]
allowHeaders: [Content-Type, Authorization]
maxAge: 3600
backendRefs:
- name: api-svc
port: 8080
ListenerSet 마이그레이션 하는 김에 EnvoyFilter로 박혀있던 CORS 설정도 같이 정리했다. EnvoyFilter는 Istio 전용 escape hatch라 vendor lock-in이었는데, 이제 표준 API로 옮길 수 있다.
다음 단계
ListenerSet 도입 자체는 무난했는데, 진짜 효과는 새 팀 온보딩에서 드러난다. 지난주에 새로 들어온 데이터 플랫폼 팀이 자기 namespace에 ListenerSet 하나 만들고 끝났다. 플랫폼 팀 리뷰 없이. 이게 우리가 원하던 그림이다.
아직 검증 못 한 부분도 있다. ListenerSet 간 priority가 어떻게 정해지는지, conflict 났을 때 deterministic하게 어느 쪽이 이기는지 같은 디테일. spec은 읽었는데 실제 동작은 구현체마다 다를 수 있어서 좀 더 굴려봐야 알 것 같다.
혹시 다른 분들은 어떻게 멀티팀 Gateway 관리하시는지 댓글 남겨주시면 좋겠다.