쿠버네티스 API 게이트웨이에 JWT 검증 추가하기 (w/ Envoy Gateway)

Yuneui the Tech Nerd
user spoon, The ResearchOps
13 min readJun 12, 2024

이 글은 API 게이트웨이에 JWT 검증을 추가하여 인증의 일원화를 달성한 예시를 소개해 드리기 위한 포스트입니다. 또한, 이 포스트를 통해 서비스가 여러 개로 분리된 상황에서 일관성 있는 인증 과정을 만들기 위한 사례를 공유하고자 합니다.

요즘 디비디랩의 개발팀은 유저스푼 개발의 일환으로 기존 시스템을 마이크로서비스로 분리하는 작업을 진행하고 있습니다. 이전 포스트(AWS EKS 환경에서 Istio를 통한 Gateway API 도입 사례)에서 API 게이트웨이 도입과 쿠버네티스의 Gateway API에 대해 소개해 드렸는데요. 이번 포스트에서는 특히 Envoy Gateway를 사용한 API 게이트웨이에서 JWT 검증을 수행한 사례를 소개해 드립니다.

지난 포스트에서는 Istio를 Gateway API의 컨트롤러로서 사용했는데, 실제 구현 단계에서는 Envoy Gateway를 사용하게 되었습니다. 처음에 Istio를 고려했던 이유는 서비스 메시의 기능을 염두에 두었기 때문인데요. 현재 저희의 서비스의 규모를 생각했을 때, 아직 서비스 메시의 장점인 복잡한 시스템의 가시성과 east-west 트래픽에 대한 컨트롤이 필요 없다고 판단하였습니다. 그렇다면 워크로드의 한계가 있는 상황에서 메모리 등 자원을 많이 소비하는 Istio 대신 Envoy 기반의 API 게이트웨이를 사용하는 것이 더 적합하다고 생각하였습니다. 그런 이유로, 지금은 Envoy에서 관리하고 있는 Envoy Gateway를 채택하여 우리 서비스의 Gateway API 컨트롤러로 사용하고 있습니다.

하이레벨 시스템 디자인

인증(Authentication)

인증은 각 마이크로서비스에서 수행하는 것이 아니라 하나의 전담 서비스에서 수행하도록 합니다. 각 서비스가 하나의 프로덕트로 기능하므로, 하나의 JWT로 모든 서비스를 사용할 수 있는 것이 자연스럽습니다. 이러한 상황에서 인증에 대한 책임을 각 서비스에서 져야 한다면 구현이 매우 번거로울 것입니다.

인증 서비스는 시스템 요구사항에 맞게 서버 애플리케이션의 형태로 자유롭게 구현하되, RSA 키를 사용하여 커스텀 클레임을 포함한 JWT를 발급할 수 있어야 합니다. 저희의 경우 OAuth2를 통한 인증도 구현되어 있습니다.

인증 과정의 흐름은 그림과 같습니다.

  1. 클라이언트가 인증 엔드포인트로 요청을 보냅니다.
  2. API 게이트웨이가 인증 서비스로 해당 트래픽을 라우팅합니다.
  3. 인증이 완료되면 인증 서비스에서 JWT를 발급합니다.
  4. 클라이언트에 JWT 등이 담겨 있는 HTTP 응답을 반환합니다.

인증 완료된 요청

유효한 JWT를 헤더에 가지고 있는 요청은 인증된 요청입니다. 인증에 대한 검증을 API 게이트웨이에서 수행하여 각 마이크로서비스에서 인증 과정을 신경쓰지 않고 인가(authorization) 과정에만 집중할 수 있습니다.

이때, 클러스터 내부에서 오고 가는 HTTP 트래픽은 중요 정보가 헤더에 평문으로 노출되어 있으므로, 퍼블릭 네트워크를 경유하게 설정되어 있는 경우 mTLS 등의 추가 보안 조치를 취해야 합니다. 이 부분은 본 포스트에서 다루지 않습니다.

인증 완료된 요청의 흐름은 그림과 같습니다.

  1. 클라이언트가 JWT 헤더와 함께 /foo 로 요청을 보냅니다.
  2. API 게이트웨이가 JWT를 검증합니다.
  3. 검증 결과가 올바르다면, 커스텀 클레임을 헤더에 설정합니다.
  4. API 게이트웨이의 라우팅 규칙에 따라 /foo 트래픽을 foo 서비스로 라우팅합니다.
  5. 헤더에 설정된 커스텀 클레임을 통해 인가를 수행합니다.
  6. 적절한 권한이 있는 클라이언트라면, 해당 요청을 정상 수행합니다.
  7. 클라이언트에 /foo 요청에 대한 HTTP 응답을 반환합니다.

Envoy Gateway의 JWT 인증 메커니즘

위의 디자인을 만족하는 인증 시스템을 구축하기 위해서 중요한 것은 API 게이트웨이가 JWT 인증이 유효한지 검증할 수 있어야 한다는 것인데요. Envoy Gateway는 Envoy에 내장된 JWT 인증 HTTP 필터를 사용해 API 게이트웨이를 통하는 트래픽의 JWT를 검증할 수 있게 설계되어 있습니다.

Envoy JWT 인증 HTTP 필터

Envoy는 Envoy Gateway 뿐만 아니라 Istio, Emissary-ingress, Contour 등 여러 API 게이트웨이 및 서비스 메시의 빌딩 블록의 역할을 하는 L7 프록시입니다. Nginx에 익숙하신 독자 분들이 많으실 텐데, 거칠게 이야기해서 Nginx의 CNCF(Cloud Native Computing Foundation) 버전이라고 생각해도 일단 상관 없겠습니다.

Envoy의 HTTP 필터 기능은 단순히 프록시 기능만 수행하는 것이 아니라, Envoy의 커넥션 매니저에 버퍼링, 레이트 리미팅 등 부가기능을 적용할 수 있게 합니다. 이것이 Envoy가 많은 API 게이트웨이 구현체에 사용되는 주된 이유라 할 수 있겠습니다.

그 중에서, JWT 인증 HTTP 필터가 Envoy에 구현되어 있는데, JWKS(JSON Web Key Set)를 이용하여 JWT를 검증할 수 있게 하는 추가 필터입니다. 또한 검증 완료된 JWT의 클레임을 헤더에 설정하는 기능도 제공합니다. 구체적인 설명과 설정 파일 예시는 위의 링크를 참조해 주세요.

Envoy Gateway에서의 Envoy HTTP 필터 적용

Envoy Gateway는 내장된 Envoy가 위에 언급한 JWT 인증 HTTP 필터를 사용하도록 설정할 수 있습니다. 필터 설정은 Envoy Gateway의 쿠버네티스 CRD(Custom resource definition)인 SecurityPolicy 자원을 생성하여 수행할 수 있습니다. 이렇게 만들어진 SecurityPolicy 자원은 Envoy Gateway의 xDS translator를 통해 Envoy 설정 파일로 변환됩니다. 구체적인 Envoy Gateway의 시스템 디자인은 여기에 링크된 문서를 참조해 주세요.

JWT 인증 적용 방법

RSA private key 생성

우선 JWT 생성과 검증이 다른 주체에서 일어나므로 RSA 비대칭 키를 생성해 줍니다. 어떤 방법을 사용해도 상관 없으나, 아래는 OpenSSL을 사용하여 private key를 생성하는 방법입니다.

openssl genpkey -algorithm RSA -out pri.pem -pkeyopt rsa_keygen_bits:2048

주의해야 할 부분은, 위에서 언급된 인증 서비스에서는 여기서 발급한 private key를 이용해서 JWT를 생성하도록 인증 로직을 구현해 주어야 한다는 점입니다.

JWKS 생성

위에서 생성한 private key에서 public key를 유도한 뒤, public key를 이용해 JWKS를 생성합니다. 어떤 방법을 사용해도 상관 없으나, 아래는 Python의 cryptography 라이브러리를 사용했습니다.

import base64
import json

from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa


def pem_to_jwk(pem_path):
with open(pem_path, "rb") as pem_file:
pem_data = pem_file.read()
private_key = serialization.load_pem_private_key(pem_data, password=None, backend=default_backend())
public_key = private_key.public_key()
numbers = public_key.public_numbers()
e = numbers.e
n = numbers.n
jwk = {
"kty": "RSA",
"n": base64.urlsafe_b64encode(n.to_bytes((n.bit_length() + 7) // 8, byteorder="big")).decode("utf-8").rstrip("="),
"e": base64.urlsafe_b64encode(e.to_bytes((e.bit_length() + 7) // 8, byteorder="big")).decode("utf-8").rstrip("="),
"alg": "RS256",
"use": "sig"
}
return jwk

def create_jwks(pem_files):
jwks = {"keys": []}
for pem_file in pem_files:
jwk = pem_to_jwk(pem_file)
jwks["keys"].append(jwk)
return jwks

if __name__ == "__main__":
pem_files = ["pri.pem"] # private key의 목록. 현재 하나만 사용하므로 pri.pem만 등록한다
jwks = create_jwks(pem_files)
jwks_json = json.dumps(jwks, indent=4)
with open("jwks.json", "w") as jwks_file:
jwks_file.write(jwks_json)

이 스크립트를 실행하고 나면 아래와 같은 JWKS 파일이 생성됩니다.

// jwks.json
{
"keys": [
{
"kty": "RSA",
"n": "yoursecret1234567890",
"e": "SECRET",
"alg": "RS256",
"use": "sig"
}
]
}

이 파일을 쿠버네티스 클러스터에서만 접근 가능한 곳에 업로드합니다. 예를 들면, 원하는 클러스터에만 접근 권한이 부여된 S3 버킷에 업로드할 수 있겠습니다.

SecurityPolicy 자원 생성

아래와 같이 Envoy Gateway의 CRD인 SecurityPolicy 자원을 생성해 줍니다.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: foo-jwt-policy
namespace: foo
spec:
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: foo-route
namespace: foo
jwt:
providers:
- name: foo-jwt
remoteJWKS:
uri: <https://some-secret-bucket.s3.your-region.amazonaws.com/some/secret/path/jwks.json>
claimToHeaders:
- claim: relevant_info
header: X-Relevant-Info
- claim: email
header: X-Email

위의 매니페스트를 설명해 드리겠습니다.

  • 하나의 SecurityPolicy 자원은 Gateway 또는 HTTPRoute 또는 GRPCRoute 자원 하나에 연결할 수 있습니다. 또한 올바르게 자원들이 연결되기 위해서 SecurityPolicy 자원의 네임스페이스와 targetRef에 해당하는 자원의 네임스페이스는 같아야 합니다. (참조: 공식 디자인 문서)
  • spec.jwt 필드는 유일한 배열 타입 필드 providers 를 갖는데, 해당 배열에 여러 개의 프로바이더가 명시될 경우, 그 중 하나만 만족하면 검증이 완료된 것으로 봅니다. (참조: 공식 API 레퍼런스)
  • Envoy의 JWT 인증 HTTP 필터에는 localJWKS 등 다양한 설정값을 제공하지만, 현재 최신 버전(v1.0.1)의 Envoy Gateway에서는 remoteJWKS 필드만을 제공합니다. 이 필드에 앞서 업로드한 JWKS 파일의 URI를 입력합니다.
  • claimToHeaders 필드를 통해 어떤 JWT 클레임이 어떤 헤더로 설정되어야 하는지 지정할 수 있습니다. 클레임의 타입은 string, int, double, bool 만 가능하며, 배열은 사용 불가능합니다.

이 설정이 적용되고 나면 Envoy Gateway에서 JWT를 생성할 때 사용된 private key를 해석할 수 있는 JWKS를 가져올 수 있게 되고, 이에 따라 JWT의 유효성을 검증할 수 있게 됩니다.

Envoy Gateway Deployment 재배포

현재 최신 버전(v1.0.1)의 Envoy Gateway에서 새로운 SecurityPolicy 자원이 생성되었을 때 곧바로 기저 Envoy에 설정이 반영되지 않는 현상이 관찰되었습니다. 이 부분은 추후 고쳐질 것으로 생각되지만, 현재 상황에서는 Envoy가 속한 Deployment를 새로 rollout하는 것으로 실제 애플리케이션의 중단 없이 재배포할 수 있습니다.

Envoy가 배포되어 있는 Deployment를 찾기 위해서는 app.kubernetes.io/managed-by=envoy-gateway 레이블을 가지고 있는지 여부를 확인하여 찾을 수 있습니다. 해당 Deployment를 찾으셨다면, 아래와 같은 명령어를 사용하여 재배포할 수 있습니다.

kubectl rollout restart deployments/<deployment-name>

만약 k9s를 사용하고 계시다면, Deployment 화면에서 “r” 키를 눌러서 같은 작업을 수행할 수 있습니다.

결과

  • JWT 토큰을 설정하지 않았을 때 → /foo 로 향하는 요청에 401로 권한이 없다는 응답을 받게 됩니다.
  • JWT 토큰을 설정했을 때 → /foo 로 향하는 요청에 200으로 정상 응답이 반환됨

결론

위의 과정을 통하여 Envoy에 내장된 HTTP 필터를 응용하여 API 게이트웨이에서 JWT 검증을 수행할 수 있도록 설정해 주었습니다. 이제 시스템에 JWT를 발급하는 중앙화된 인증 서비스를 두고, 검증은 API 게이트웨이에서 수행하여 각 마이크로서비스의 인증 구현 책임이 사라졌습니다. 이제 하나의 토큰으로 게이트웨이가 허용하는 라우트에 대한 인증은 모두 가능해지며, 각 서비스는 권한에 따라 요청을 처리할지 여부를 판가름하는 인가(authorization)에 대한 구현 책임만 지면 됩니다.

--

--