“차라리 도메인 바로 뒤에 API Gateway 같은 걸 두면 한 번에 해결되지 않을까.”

CloudFront 앞단이 막힌 날 오후에 든 생각이다. 하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서, 기존 환경(A)과 SaaS 환경(B)을 주소 하나로 묶으려고 CloudFront를 기존 도메인 앞에 세우려 했다. CloudFront를 거치면 뒤쪽 로드밸런서가 보는 출발지 IP가 바뀌어서, 사무실 IP 규칙과 고정 IP에 기대는 외부 연동처가 한꺼번에 걸렸다. 그 사정은 다 만들고 적용하지 않은 CloudFront 앞단에 적었다.

남은 질문은 로그인 토큰(JWT)의 claim을 읽고 요청을 A로 보낼지 B의 어느 테넌트로 보낼지 정하는 일을 어디서 할 것인가였다. A에서 B로 조금씩 옮기는 이관에서 이 지점을 Strangler Fig 패턴의 파사드(facade)라고 부르는데, 당시 그 파사드는 운영사 ALB였다. ALB 규칙은 운영사 운영팀의 Ingress가 원본이라 우리가 고칠 수 없다. 꼬인 원인은 CloudFront라는 제품이 아니라 파사드가 우리 손에 없다는 데 있었다. 그래서 NLB의 고정 IP와 도메인은 그대로 두고, NLB 바로 뒤에 우리가 소유한 프록시를 세우기로 했다. 이름에 “Gateway”가 들어간 후보가 여럿이었고, 나는 그중 AWS API Gateway가 가장 먼저 떠올랐다.

이 자리에 요구되는 것

후보를 보기 전에 요구사항부터 적었다. 앞에서 CloudFront 앞단과 호스트명 분리를 시도하며 하나씩 걸렸던 것들이다.

  1. NLB 뒤에 붙어야 한다. 외부 연동처 약 100곳이 NLB 고정 IP 2개를 방화벽 허용 목록에 넣어 두었다.

  2. JWT payload에서 saasTenantId를 읽어, 값이 있으면 B의 해당 테넌트로, 없으면 A로 보내야 한다.

  3. A로 보낼 때 Host를 유지해야 한다. 운영사 ALB 규칙은 전부 host-header가 www.example.com일 때만 걸린다.

  4. /mo 같은 프론트엔드 경로는 S3 정적 파일로도 보낼 수 있어야 한다.

  5. /cp, /sp 같은 관리자 경로의 사무실 IP 제한을 유지해야 한다. 프록시가 클라이언트 IP를 볼 수 있어야 한다.

  6. HTML, 정적 파일, 긴 요청까지 사이트 전체 트래픽이 지나가니 페이로드·타임아웃 제약이 작아야 한다.

  7. A로 가는 요청은 지금보다 무조건 한 홉 이상 늘어난다. 얼마나 늘어나는지가 비용이다.

API Gateway는 경로와 메서드로만 나눈다

이름만 보면 가장 잘 맞아 보였는데, 요구사항에 하나씩 대 보니 거의 다 어긋났다. 우선 API Gateway는 NLB의 대상이 될 수 없다. 붙이려면 Private REST API와 interface VPC 엔드포인트를 두어 NLB, VPC 엔드포인트, API Gateway 순으로 이어야 한다. VPC 엔드포인트를 지나면 API Gateway가 보는 출발지도 사용자 IP가 아니게 되니, 사무실 IP 제한은 여기서 할 수 없다.

더 큰 문제는 라우팅이다. API Gateway의 라우팅은 경로와 메서드로만 정해진다. 헤더 값, 하물며 JWT payload 안의 claim 값으로 통합 대상을 바꾸는 설정은 없다. Lambda authorizer가 돌려주는 것도 허용/거부 정책과 컨텍스트 값이라 요청을 어디로 보낼지는 바꾸지 못한다. 결국 Lambda를 프록시로 세워 JWT를 읽고 A나 B로 다시 HTTP 요청을 보내야 하는데, 그러면 콜드 스타트가 생기고 API Gateway의 통합 타임아웃(기본 29초)과 페이로드 한도(10MB)를 사이트 전체가 떠안는다. HTTP 통합의 Host는 통합 엔드포인트 기준으로 정해지고 매핑으로 덮어쓸 수 없어서, A로 보낸 요청이 운영사 ALB 규칙에 걸리지도 않는다.

API Gateway:  NLB → VPC 엔드포인트 → API Gateway → Lambda(프록시) → ALB → Pod
Istio:        NLB → ingress gateway → ALB → Pod

“홉이 하나 늘어서 느려지지 않을까”를 걱정하며 API Gateway를 떠올렸는데, JWT 분기를 하려면 Lambda 프록시가 필수라 홉이 두 개 더 붙는다. 이 시점에 API Gateway는 접었다. API 키, 사용량 플랜, 스로틀링처럼 API를 상품으로 관리하는 일에 맞춘 물건이라, 웹사이트 전체를 받아 내용에 따라 목적지를 바꾸는 리버스 프록시와는 용도가 다르다.

나머지 후보도 금방 갈렸다. Kong, Apigee, Tyk는 JWT claim으로 라우팅할 수 있지만 결국 EKS에 프록시 Deployment를 하나 더 띄우는 것이라, Istio와 같은 자리, 같은 홉에 운영할 제품만 하나 늘어난다. 우리가 만든 ALB는 JWT를 디코딩하지 못하고 다른 VPC의 B 환경으로 forward하지도 못한다. CloudFront + Lambda@Edge는 앞단이 CloudFront라는 점이 그대로라 출발지 IP 문제로 원점이다.

이미 돌고 있는 Istio ingress gateway

B 환경에는 테넌트 트래픽을 받는 Istio ingress gateway가 이미 떠 있었다. 테넌트 게이트웨이에 A 전체 트래픽의 부하와 장애가 번지지 않도록, 같은 istiod가 관리하는 두 번째 게이트웨이 Deployment(istio-gateway-legacy)를 따로 세우는 안으로 잡았다. 추가 비용은 Pod 몇 개다.

VirtualService는 JWT를 직접 열지 못하니 claim을 HTTP 헤더로 먼저 꺼내야 한다. Istio 표준은 서명 검증까지 해 주는 RequestAuthentication의 outputClaimToHeaders다. 그런데 A 환경 인증 서버의 토큰 검증 방식이 이 기본 JWT 인증기(JWKS 기반 공개키 검증)와 바로 맞지 않았다. 검증용 키를 게이트웨이 설정에 따로 넣는 방법도 있었지만, 인증 키를 라우팅 계층까지 퍼뜨리는 부담이 라우팅 단계에서 한 번 더 검증하는 이득보다 크다고 봤다. 그래서 payload를 라우팅 용도로만 읽는 Lua 필터를 골랐다. 토큰의 진위 검증은 원래부터 뒤쪽 백엔드의 일이다.

apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: jwt-decode-tenant
  namespace: istio-gateway
spec:
  workloadSelector:
    labels:
      istio: istio-gateway-legacy        # 파사드용 게이트웨이에만 건다
  configPatches:
  - applyTo: HTTP_FILTER
    match:
      context: GATEWAY
      listener:
        filterChain:
          filter:
            name: envoy.filters.network.http_connection_manager
            subFilter:
              name: envoy.filters.http.router
    patch:
      operation: INSERT_BEFORE
      value:
        name: envoy.lua.jwt-tenant
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
          inlineCode: |
            function envoy_on_request(h)
              -- 클라이언트가 직접 붙인 헤더는 먼저 지운다. 이 헤더는 게이트웨이만 붙인다
              h:headers():remove("x-saas-tenant-id")
              local auth = h:headers():get("authorization")
              if auth == nil then return end
              -- JWT의 두 번째 덩어리(payload)만 꺼낸다. 라우팅용으로만 읽고 인증은 뒤에서 한다
              local payload = auth:match("^[Bb]earer%s+[^%.]+%.([^%.]+)%.")
              if payload == nil then return end
              payload = payload:gsub("-", "+"):gsub("_", "/")
              payload = payload .. string.rep("=", (4 - #payload % 4) % 4)
              local ok, json = pcall(function() return h:base64Decode(payload) end)
              if not ok then return end
              local tenant = json:match('"saasTenantId"%s*:%s*"([%w%-]+)"')
              if tenant then h:headers():add("x-saas-tenant-id", tenant) end
            end

VirtualService는 이 x-saas-tenant-id 헤더 하나만 본다. 테넌트로 보낼 때는 authority를 테넌트 호스트로 바꿔 게이트웨이로 되돌리니, 이미 있는 테넌트 VirtualService가 경로별 라우팅을 그대로 한다. 나머지는 Host를 www 그대로 둔 채 A 환경 ALB로 보낸다.

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: legacy-facade
  namespace: www
spec:
  gateways:
  - istio-gateway/istio-gateway-legacy
  hosts:
  - "www.example.com"
  http:
  # 1) 토큰에 테넌트가 있으면 테넌트 호스트로 바꿔 게이트웨이로 되돌린다 (테넌트당 한 블록)
  - match:
    - headers:
        x-saas-tenant-id:
          exact: "tenant-a"
    rewrite:
      authority: tenant-a.saas.example.com
    route:
    - destination:
        host: istio-gateway.istio-gateway.svc.cluster.local
        port:
          number: 80
  # 2) 정적 프론트엔드는 CloudFront/S3로 (ServiceEntry 필요)
  - match:
    - uri:
        prefix: /mo
    route:
    - destination:
        host: d111111abcdef8.cloudfront.net
        port:
          number: 443
  # 3) 나머지는 전부 A 환경 ALB로. Host는 www 그대로라 운영사 ALB 규칙에 걸린다
  - route:
    - destination:
        host: a-alb-1234567890.ap-northeast-2.elb.amazonaws.com
        port:
          number: 443

A로 보내는 목적지는 반드시 ALB의 DNS 이름이어야 한다. www.example.com으로 보내면 그 이름이 다시 우리 NLB로 풀려 게이트웨이가 자기 자신을 부르는 루프가 된다. 사무실 IP 제한은 AuthorizationPolicy의 remoteIpBlocks로 옮길 수 있었다. 다만 이 값은 게이트웨이가 원래 클라이언트 IP를 볼 때만 정확해서, externalTrafficPolicy: Local이나 numTrustedProxies 설정과 함께 PoC에서 확인할 항목으로 남겼다.

그래도 이 구조는 채택하지 않았다

요구사항별로 놓고 보면 Istio가 이겼다. 기능은 Kong과 같고, 새로 운영할 제품이 없다. 지연은 추정만 했는데, NLB에서 게이트웨이까지는 같은 리전 안이라 +1~3ms 정도로 잡았다.

표에 안 들어가는 비용이 더 컸다. A 환경의 운영 트래픽 전부가 B 클러스터를 지나게 되니, 게이트웨이의 HA, 용량, 모니터링이 곧 A 서비스의 가용성이 된다. A 환경 NLB의 대상을 ALB에서 우리 게이트웨이로 바꾸는 운영사 작업도 한 번은 필요했다. 기간은 PoC 1~2주, 전환 협의까지 한 달 단위로 봤다.

비교에서는 Istio Gateway 파사드가 이겼지만 이 구조는 결국 채택하지 않았다. CloudFront 앞단을 접은 것처럼 이 파사드 안도 적용하지 않았고, 최종적으로 어떤 구조로 갔는지는 이 글에서 다루지 않는다.

JWT 값을 보고 목적지를 바꾸는 요구를 AWS 관리형 서비스만으로 푸는 방법은 이번에 찾지 못했다.