Jaehong Jung

HTTPRoute에서의 RegularExpression Path Matching

Kubernetes Gateway API와 HTTPRoute

Kubernetes Gateway API는 Kubernetes에서 서비스 네트워킹을 모델링하는 리소스 모음입니다. 그 중 HTTPRoute는 HTTP 요청을 매칭하고 백엔드 서비스로 전달하는 라우팅 규칙을 정의하는 리소스입니다.

HTTPRoute를 다루면서 가장 중요한 것 중 하나는 바로 여러 개의 HTTPRoute를 정의했을 때 어떻게 Rules들이 Merge 되는가입니다.

여러 개의 HTTPRoute가 하나의 Gateway에 attach 되었을 때, Proxy 혹은 LoadBalancer는 무조건 다음과 같은 순서로 우선순위를 부여해야합니다.

  1. Exact path match
  2. Prefix path match (높은 character count 수)
    • PathPrefix: /col 이라고 해서 /color로 라우팅되지 않음
  3. RegularExpression path match (Istio에서만 지원)
    • Istio에서는 RE2 syntax 사용 (e.g. .* matches any character)
  4. Method match
  5. Largest number of header matches
  6. Largest number of query param matches

Istio 코드 기반: Path Match 우선순위의 동작방식

Istio는 Gateway API 기반 HTTPRoute를 처리할 때, Path Matching Rule의 우선순위를 아래와 같이 소스코드에서 처리합니다.

createURIMatch()

Gateway API의 Path Match 타입을 Istio 내부 StringMatch로 변환하는 함수입니다:

func createURIMatch(match k8s.HTTPRouteMatch) (*istio.StringMatch, *ConfigError) {
    tp := k8s.PathMatchPathPrefix
    if match.Path.Type != nil {
        tp = *match.Path.Type
    }
    dest := "/"
    if match.Path.Value != nil {
        dest = *match.Path.Value
    }
    switch tp {
    case k8s.PathMatchPathPrefix:
        if dest != "/" {
            dest = strings.TrimSuffix(dest, "/")
        }
        return &istio.StringMatch{
            MatchType: &istio.StringMatch_Prefix{Prefix: dest},
        }, nil
    case k8s.PathMatchExact:
        return &istio.StringMatch{
            MatchType: &istio.StringMatch_Exact{Exact: dest},
        }, nil
    case k8s.PathMatchRegularExpression:
        return &istio.StringMatch{
            MatchType: &istio.StringMatch_Regex{Regex: dest},
        }, nil
    default:
        return nil, &ConfigError{...}
    }
}

getURIRank()

각 match type에 숫자 rank를 부여하는 함수입니다. 리턴값이 클수록 우선순위가 높습니다:

// getURIRank ranks a URI match type. Exact > Prefix > Regex
func getURIRank(match *istio.HTTPMatchRequest) int {
    if match.Uri == nil {
        return -1
    }
    switch match.Uri.MatchType.(type) {
    case *istio.StringMatch_Exact:
        return 3
    case *istio.StringMatch_Prefix:
        return 2
    case *istio.StringMatch_Regex:
        return 1
    }
    return -1
}

sortHTTPRoutes()

getURIRank를 사용해서 route를 우선순위별로 정렬하는 함수입니다:

func sortHTTPRoutes(routes []*istio.HTTPRoute) {
    sort.SliceStable(routes, func(i, j int) bool {
        // ...
        r1, r2 := getURIRank(m1), getURIRank(m2)
        len1, len2 := getURILength(m1), getURILength(m2)
        switch {
        case r1 != r2:
            return r1 > r2 // 우선순위 높은 쪽이 먼저
        case len1 != len2:
            return len1 > len2
        case (m1.Method == nil) != (m2.Method == nil):
            return m1.Method != nil
        case len(m1.Headers) != len(m2.Headers):
            return len(m1.Headers) > len(m2.Headers)
        default:
            return len(m1.QueryParams) > len(m2.QueryParams)
        }
    })
}

핵심은 Exact(3) > Prefix(2) > Regex(1) 입니다. Regex를 만족하는 PathPrefix가 선언되어 있으면 RegularExpression으로는 절대 매칭되지 않습니다.

Kubernetes Gateway API 스펙 자체에서는 “RegularExpression” path matches의 우선순위를 정의하지 않고 각 구현체에게 맡깁니다. 정해진 스펙이 없기 때문에, 사용자는 자신이 사용하는 구현체가 어떻게 구현했는지를 따를 수 밖에 없습니다. Istio의 경우 getURIRank를 통해서 URI match type을 Exact > Prefix > Regex 순서로 우선순위를 가져갑니다. 그러므로, Regex를 아무리 복잡하게 설정하더라도, Route에 부합하는 PathPrefix가 존재한다면, PathPrefix match를 우선적으로 적용합니다.

예를 들어 다음과 같이 설정된 경우:

  1. PathPrefix: /api/v1/ -> backend-service
  2. RegularExpression: /api/v1/hooks/.*/callback -> webhook-handler

/api/v1/hooks/provider/callback 요청은 무조건 PathPrefix가 먼저 적용되어 RegularExpression이 적용되지 않습니다.

만약 RegularExpression을 써야한다면, unexpected behavior를 방지하기 위해서 gateway에서 같은 hostnames 그룹 내에서는 PathPrefix 대신 모두 RegularExpression을 사용해야 합니다 (e.g. RegularExpression: /.*).

실전 예시: Gateway/Waypoint 분리 구조

(1) Gateway에서 PathPrefix로 대분류

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: public-ingress-route
spec:
  parentRefs:
    - name: istio-public-ingress
      namespace: istio-gateway
  hostnames:
    - "api.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: "/"
      backendRefs:
        - name: backend-svc
          port: 8080

(2) Waypoint에서 RegularExpression으로 세밀 분기

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: backend-svc-waypoint
spec:
  parentRefs:
    - kind: Service
      name: backend-svc
  rules:
    - matches:
        - path:
            type: RegularExpression
            value: "/.*"
      backendRefs:
        - name: backend-svc
          port: 8080
    - matches:
        - path:
            type: RegularExpression
            value: "/api/v1/hooks/.*/callback"
      backendRefs:
        - name: webhook-handler
          port: 8080

Waypoint Rules -> Envoy Config 변환 관련 Tip

  1. waypoint.rules가 아예 blank일 때, default로 backend-svc Envoy 클러스터로 forwarding하는 rules가 채워져 있습니다.
  2. 만약 waypoint.rules/api/v1/hooks/.*/callback RegularExpression만 존재하면, 해당 조건을 만족하는 요청만 routing되고, 나머지 모든 request는 404 NOT FOUND가 발생합니다.
  3. "/.*""/api/v1/hooks/.*/callback"둘 다 존재해야 expected behavior가 재현됩니다. 그리고 Waypoint와 Gateway에 각각 별도의 Envoy에 bound 되는 rules이기 때문에, Gateway의 PathPrefix rules의 영향을 받지 않습니다.

결론

  • Istio는 소스코드 레벨에서 PathPrefix > RegularExpression 우선순위를 강제합니다.
  • 실전에서는 Gateway와 Waypoint를 명확하게 분리해서 사용하는 것을 추천합니다.
  • 이 구조를 모르고 HTTPRoute에서 (같은 hostname 안에서) PathPrefix/Regex를 혼합하면, 실제로는 PathPrefix가 다 먹어버리는 함정이 생깁니다.