6W - api 구조와 보안 1 - 인증

개요

이번 문서에서는 쿠버네티스의 api서버의 엔드포인트 구조와 접근 제어 방식에 대해 알아본다.
EKS에서 api서버에 대해 제공하는 각종 보안 기능은 결국 api 서버에서 제공하는 방식을 이용하기 때문에, 이것들을 제대로 알아두면 도움이 된다.
구체적으로 먼저 api 서버에 요청을 날릴 때 사용하게 되는 경로와 이에 관련된 개념들을 훑는다.
그 다음 api 서버에 요청이 수행되기 전 거치는 3단계를 알아보고, 이 중 인증 단계에 대한 실습을 진행한다.
인가와 승인 제어 단계는 다음 글에서 진행하도록 한다.
각 과정에서 많은 실습을 할 수 있지만, 다룰 내용이 많기 때문에 가능한 실습 내용을 줄이고자 한다.

사전 지식

API 구조

api 서버에는 요청을 어떻게 날려야 하는가?
사실 api서버는 전형적인 WAS 서버일 뿐이다.
그래서 실제로 curl https://{api server address}/ 이런 식으로 요청을 날리는 것이 가능하다.
근데 이 내부적으로 꽤나 정교하게 만들어진 하위 엔드포인트가 있다.
이를 명확히 하기 위해서 먼저 API 구조에 대해서 알아본다.
일단 가장 큰 범주에서 API는 두 가지로 구분지을 수 있다.

비 리소스(Non-resource)

아래 리소스에 해당하지 않는 모든 엔드포인트는 비 리소스로 간주된다.
클러스터에 대한 조작을 가하지 않거나, 클러스터 내부의 요소들에 대한 상태를 확인하지 않는 요청들이 여기에 해당한다.
대표적으로 /statusz, /healthz 등이 여기에 해당한다.
여기에 해당하는 것은 거의 없으니, 대체로 api라고 한다면 다음의 리소스를 생각해도 무방하다.

리소스(Resource)

리소스는 클러스터를 조작하거나 관리하는데 사용되는 모든 엔드포인트를 말한다.
이 엔드포인트에 각종 HTTP 메서드를 이용해 요청을 날리는 방식으로 api 서버를 조작하게 된다.

POST /api/v1/namespaces/default/pods

가령 default 네임스페이스에 파드를 만든다면, 이런 식으로 요청을 날리면 된다.
(바디에 파드 관련 스펙을 적어줘야 한다.)

개념 정리

명확한 용어 정의를 조금 더 내려보자.

파드로 예를 들어본다면, 파드 자체는 리소스 유형이고, 그 중에서 A 파드 자체를 꼭 집어 말한다면 그것은 리소스이다.
그리고 여러 파드를 한꺼번에 표현한다면 그것은 컬렉션이라 부른다.
근데 뭐.. 실제로는 그냥 파드라 하면 우리는 흔히 리소스를 떠올리고 그렇게 표현하곤 한다.
그렇게 표현해도 어차피 의사소통에 그다지 문제될 게 없으니 개념을 이런 식으로 볼 수 있다고만 알고 있으면 된다.
image.png
참고로 굳이 컬렉션을 명시적으로 개념화한 이유는 이런 것 때문이다.
여러 개의 리소스를 조회하는 요청을 할 때 돌아오는 응답은 큰 kind가 {어떤 리소스}List가 되고, items필드에 어떤 리소스들의 원소가 들어가게 된다.
api 서버가 응답을 하는 값에는 이 컬렉션이 활용되기 때문에 이를 굳이 컬렉션이라고 부른다.

kubectl로 보니 그렇게 안 오던데?

이 말을 듣고 kubectl에 -o json으로 명령을 내려본 당신, 막상 보니 kind가 List로 돼있을 것이다.
kubectl은 여러 개의 리소스 유형을 한꺼번에 조회하는 기능을 지원하기 때문에, 기본적으로 모든 아이템을 받은 후에 이를 List라는 kind로 출력해서 보여준다.
즉 그저 클라이언트 사이드에서만 kind: List가 있을 뿐, 실제로 api 서버에는 그런 kind가 없다는 것에 유의하자.

API 그룹

/api/v1/pods
/apis/apps/v1/deployments
/apis/apps/v1/namespaces/my-namespace/deployments/my-deployment

이 모든 리소스는 저마다 그룹에 속해있는데, 이를 API 그룹이라 부른다.
위의 예시에서, 파드 리소스 유형은 v1이라는 그룹에 속해있는 것이다.
그리고 디플로이먼트 리소스 유형은 apps/v1이라는 그룹에 속한다.
보다시피 API 그룹은 기본적으로 버전 정보와 어떤 역할을 한다던지에 대한 정보가 담긴다.

그럼 /api/apis는 무엇인가?
이것들은 모든 리소스의 엔드포인트가 시작하는 경로로써 다음과 같이 정리된다.

코어 그룹이라 하니 여기에만 클러스터에 필수적인 요소들이 들어갈 것만 같지만, 실상은 다르다.
네임드 그룹에는 현재 흔하디 흔하게 쓰이는 디플로이먼트, 인그레스, 컨피그맵 등 다양한 리소스들이 들어가 있다.
그래서 실질적으로 코어 그룹은 처음 쿠버네티스가 만들어질 당시 지정된 리소스들을 규정하는 방식의 잔재라고 보는 게 낫다.

이제 조금씩 명확해진다.
api 서버에 명령을 내려 클러스터를 조작하고 싶다면, 먼저 대상이 될 리소스를 지정한다.
그리고 그 리소스의 API 그룹과 버전을 앞단 경로로 작성하고, 리소스 유형을 적는다.
(만약 특정 네임스페이스로 한정 짓고 싶다면 네임스페이스를 먼저 적어준다.)
그 다음에는거기에 HTTP 메서드를 이용해서 명령을 내리면 되는 것이다!
이러한 방식을 쉽게 지원하는 명령줄 도구 중 하나가 바로 kubectl인 것이다.

오브젝트

쿠버네티스 오브젝트는 시스템 내의 영속적인 개체들을 말한다.
이 개체들은 클러스터의 상태를 나타내기 위해 사용된다.[1]

kube-apiserver에서 조작을 가하는 리소스 중에서, etcd에 정보가 남는 리소스를 특별히 오브젝트라고 부른다.
오브젝트는 명확하게 상태 정보가 저장되며, 클러스터는 이 정보들을 이용해 필요한 자원을 활용하거나 조작한다.
말 그대로 객체, 쿠버네티스에서 개념지을 수 있을 만한 모든 것은 오브젝트로 환원된다고 무방하다.
예를 들자면 이런 것들이다.

우리가 클러스터를 관리한다고 하는 것은 실상 이 오브젝트들을 조작하는 행위이고, 쿠버네티스는 우리가 이런 오브젝트들을 던져주면 해당 사항을 클러스터에 반영한다(구체적으로는 하고자 노력한다).

오브젝트가 아닌 것

그렇다면 리소스 중에서 오브젝트가 아닌 게 뭐가 있을까?
언뜻 생각하면 kubectl로 조작해서 kind가 지정되는 모든 것들이 오브젝트일 것만 같다.
그러나 엄밀하게, kind는 리소스의 형식이 어떻게 된다를 명시하기 위한 스키마에 불과하다.

{
  "apiVersion": "authentication.k8s.io/v1",
  "kind": "SelfSubjectReview"
}

이건 쿠버네티스 인증에서 사용되는 리소스로, 현재 요청을 보내는 클러스터 입장에서 누구인지를 물어보는 api 요청이다.
당연히 이것은 단순하게 물어보는 동작일 뿐이므로, etcd에는 저장되지 않는다.

POST {api 주소}/apis/authentication.k8s.io/v1/selfsubjectreview

실제 api 엔드포인트는 이런 식으로 지정이 될 텐데, 이런 식으로 kind가 지정이 돼있어도 실상 오브젝트가 아닌 것들이 있다.
오브젝트가 아닌 리소스가 흔하지는 않지만, 그래도 간혹 이런 것들이 있기 때문에 kind 스키마가 있다고 무작정 오브젝트라고 보는 것은 조금 혼란을 야기할 수 있다.

다시 정리하자면, 리소스 중에 etcd에 저장되어 클러스터에 영구적인 상태가 담기는 리소스가 바로 오브젝트라고 이해하면 되겠다.

양식(manifest)와 kind

쿠버네티스의 모든 오브젝트들은 기본적인 양식이 정해져 있다.
이때 이러한 양식을 manifest라고 부르는데, 이것은 오브젝트에 대한 정형화된 스키마(kind)를 포함하는 개념이다.
kind는 어떤 리소스 타입에 대한 스키마를 의미하는데, 실상 이것은 어떤 api 엔드포인트(리소스)에 대해 조작을 할 때 어떤 식으로 바디를 구성해서 보내야 하는지를 지정한 형식이다.
다시금 정리를 해보자면, 어떤 오브젝트에 대해 해당 리소스가 어떤 api그룹에 속하는지, 어떤 스키마를 가지는지(kind), 그리고 그 스키마의 내용물이 어떻게 작성됐는지를 전부 합쳐서 파일로 만든 것이 흔히 말하는 양식, 매니페스트이다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2 # tells deployment to run 2 pods matching the template
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.14.2
        ports:
        - containerPort: 80

디플로이먼트의 양식 예시를 보며 조금 더 구체화해보자.
여기에서 아주 기본적인 필드는 다음과 같다.

구조 정리

간단하게 도식을 정리하면 이렇게 볼 수 있다. 결론적으로 api 서버에 명령을 내려 클러스터를 조작하고 싶다면, 먼저 대상이 될 리소스를 지정한다. 그리고 그 리소스의 API 그룹과 버전을 앞단 경로로 작성하고, 리소스 유형을 적는다. (만약 특정 네임스페이스로 한정 짓고 싶다면 네임스페이스를 먼저 적어준다.) 그 다음에는거기에 HTTP 메서드를 이용해서 명령을 내리면 되는 것이다! 이러한 방식을 쉽게 지원하는 명령줄 도구 중 하나가 바로 kubectl인 것이다.

API 서버 보안

보안에 있어서 가장 중요한 것 중 하나는 클러스터를 아무나 조작하거나 조회할 수 없도록 하는 것이다.

그래서 kube-apiserver에서는 실제 명령이 시행되기에 앞서 다양한 스텝을 거쳐서 명령을 검증한다.
이 과정은 크게는 4가지 과정을 거친다고 할 수 있다.
더 자세한 내용은 시큐리티#API 서버 보안에 담겨 있다.

1. 인증(Authentication)

트래픽을 받는 첫 단계는 인증으로, 상대가 누군지를 확인하는 단계이다.
먼저 api 요청을 보낸 상대가 누군지, 그래서 클러스터에 조작을 가해도 괜찮은 대상인지를 확인하는 과정이다.
인증 단계에서는 HTTP 요청 전체를 받아서 전형적으로는 헤더 정보만 확인하여 인증 정보를 체크한다.
자세한 내용은 Authentication에 았고, 여기에서는 실습에 필요한 부분만 추출한다.

요청 주체

먼저 클러스터에 요청을 수행할 수 있는 주체는 계정(account)인데, 크게 두 가지로 분류된다.
이렇게 분류한다고 해서 api 서버가 인증 단계에서 둘을 따로 인지하고 처리한다는 것은 아니다.
이 두 유형의 계정 모두 요청을 하더라도 일단 익명의 요청으로 간주되고, 인증 단계를 거치게 된다.

유저 계정

우리 같은 일반적인 사람을 이야기한다.
유저는 여러 방법으로 클러스터에 접근할 수 있다.

쿠버네티스는 유저 계정에 대한 정보를 일체 저장하지 않는다.
유저 이름을 API 접근을 검사하는 다른 단계에서 또 활용하기는 하지만, User 오브젝트를 가지고 있지도 않고, 관련 정보를 api 서버에 절대 보관하지 않는다.
이것 때문에 유의할 것이 하나 있다면, 클러스터에 접근할 수 있는 유저 계정을 관리하고 싶은 조직이라면 별도의 방안을 마련해야 한다.

유저란 표현

앞으로 문서에서 유저라는 표현이 자주 등장할 텐데, 그 유저가 "유저 계정"을 의미하는 게 아닐 때가 많다.
즉, 보통 인증 단계에서 유저라고 하면 해당 계정의 신원을 나타낼 때 사용하는 값이다.
그래서 앞으로 만약 유저 계정을 의미하고 싶을 때는 명시적으로 "유저 계정"이라고 명시한다.

서비스 어카운트

서비스 어카운트는 쿠버네티스 API를 통해 관리되는 계정이다.
특정 네임스페이스에 달라붙으며, kube-apiserver에 의해 만들어진다.
이 계정에 대한 정보는 Secret에 저장되고, 파드에서 이를 마운팅하여 클러스터 내부에서 파드가 해당 계정으로서 API와 통신할 수 있게 된다.

인증 모듈

쿠버네티스에서는 클라이언트 인증서, 베어러 토큰, 인증 프록시 등을 사용해 인증 요청을 처리한다.
다양한 인증 방법을 마련한 만큼, 이것들을 별도의 모듈로 분리해서 관리할 수 있도록 해두었다.
이 덕분에 관리자는 인증 수단을 여러 개 모듈로 적용할 수 있다.
이 인증 모듈들은 api 요청이 들어왔을 때 차례차례 인증 로직을 수행하는데, 이때 어떤 모듈이 먼저 수행될지에 대한 순서는 보장하지 않는다.
여기에서 주의할 것은 인증 모듈 간의 인증 절차는 OR 연산과 비슷하다는 것이다.
여러 인증 모듈이 있을 때, 하나의 인증 모듈에서만 인증됐다고 표시하면 해당 요청은 인증된 것으로 간주되고 통과된다.

HTTP 요청 자체는 api 서버가 받는다고 할 수 있다.
들어온 요청에 대해 인증 모듈은 다음의 정보를 이용해 인증을 처리할 것이다.
인증 모듈에서 인증을 위해 확인하는 값은 다음과 대체로 다음의 값이다.

인증된 모든 계정은 system:authenticated라는 그룹에 속하게 된다.
반면 인증이 되지 않은 경우는 어떤가?
이 케이스는 두 가지 경우로 나눌 수 있다.

X509 클라이언트 인증서

TLS 통신 과정 상에서 mTLS를 통해 상대의 신원을 검증할 수 있다.
이전 문서에서 보았던 kubeconfig에 인증서를 등록한 것이 바로 이 방식을 이용해 인증한 것이다.
클라이언트 인증서는 api서버의 --client-ca-file을 통해 커스텀 CA로부터도 인증을 받게 할 수 있다.

이 파일은 반드시 api 서버가 검증을 하기 위한 하나 이상의 CA을 담고 있어야 한다.
만약 인증서가 제시되고 검증됐다면, 유저의 이름은 Common Name 부분의 값으로 정해진다.
여기에 조직 필드(O)를 통해 유저의 소속 그룹도 나타낼 수 있다.
한 유저에 여러 소속 그룹을 포함하고 싶으면 그대로 조직 필드에 넣어주면 된다.

openssl req -new -key jbeda.pem -out jbeda-csr.pem -subj "/CN=jbeda/O=app1/O=app2"

이런 식으로 openssl을 통해 CSR을 만들면 유저 이름은 jbeda, 조직은 app1, app2일 것이다.

OIDC 토큰

OAuth를 지원하는 제공자를 이용해 OIDC를 쓸 수 있다.
이때 ID 토큰을 활용하게 된다.
당연하지만 이 토큰은 JWT 형식으로 돼있다.

먼저 유저가 제공자에게 access_token, id_token, refresh_token을 받고, 이 중 id_token을 HTTP 헤더에 Authorization: Bearer <token>과 같은 식으로 요청을 날린다.
그리고 api 서버는 이를 검증하는데, 미리 설정해둔 discovery URL에서 IDP의 정보와 공개키를 받아와 검증을 수행한다.
모든 정보는 id_token에 담겨져 있으므로 쿠버네티스에서 인증이 잘 됐는지에 대해 따로 제공자에게 통신을 취하지는 않는다.
즉, api 서버는 id_token이 들어왔을 때 해당 유저 계정이 이미 인증 제공자로부터 신원이 검증된 상태라고 감안한다는 것이다.
api 서버 입장에서 남은 일은 그렇게 들어온 유저, 그룹 값이 허용된 값인지만 검사하면 된다.

조금 더 예를 들어보겠다.
인증 제공자 쪽에 설정해서 a,b 유저를 만들었다.
이 두 유저는 자신의 ID 토큰을 발급받을 수 있을 것이다.
근데 api 서버에서는 a 유저에 대해서만 인증을 통과시키고 싶다.
그럼 api 서버에서는 a유저가 맞는지만 ID 토큰에서 검사한다는 것이다.

인증 모듈은 ID 토큰을 파싱하고, 일단 설정된 발급자에 의해 서명된 것인지만 검증한다.
서명을 검증하기 위한 공개키는 제공자의 URL 경로로 탐색하여 진행될 것이다.
이후에는 토큰의 claim 필드를 이용해 유저를 인증한다.
최소한의 유효한 JWT 페이로드는 다음과 같은 모양을 띄어야 한다.

{
  "iss": "https://example.com",   // 발급자의 url과 일치해야 한다.
  "aud": ["my-app"],              // 최소한 하나의 원소가 발급자의 청중과 일치해야 한다.
  "exp": 1234567890,              // 유닉스 시간 기준으로 토큰의 만료일시
  "<username-claim>": "user"      // claimMappings.username.claim이나, claimMappings.username.expression에 설정된 유저이름 요청
}

모든 요청은 stateless하기에 인증에 있어서 매우 유연한 솔루션이 된다.
몇 가지 도전과제가 있다.

관련해서 설정할 api 서버 인자가 매우 많은데, 이번 실습에서는 설정 파일을 이용할 것이므로 생략한다.
궁금하다면 Authentication#OIDC 토큰을 참고하자.

kubectl 이용하기

OIDC로 인증받고자 하는 유저가 kubectl을 이용하고 싶다면 아래처럼 해주면 된다.
kubectl에 id_token을 베어러 토큰으로 쓸 수 있도록 설정할 수 있다.
이건 심지어 토큰이 만료되면 재발급도 해준다.

kubectl config set-credentials USER_NAME \
   --auth-provider=oidc \
   --auth-provider-arg=idp-issuer-url=( issuer url ) \
   --auth-provider-arg=client-id=( your client id ) \
   --auth-provider-arg=client-secret=( your client secret ) \
   --auth-provider-arg=refresh-token=( your refresh token ) \
   --auth-provider-arg=idp-certificate-authority=( path to your ca certificate ) \
   --auth-provider-arg=id-token=( your id_token )

이런 식으로 설정해주면 된다.

users:
- name: mmosley
  user:
    auth-provider:
      config:
        client-id: kubernetes
        client-secret: 1db158f6-177d-4d9c-8a8b-d36869918ec5
        id-token: {id-token}
        idp-certificate-authority: /root/ca.pem
        idp-issuer-url: https://oidcidp.tremolo.lan:8443/auth/idp/OidcIdP
        refresh-token: {refresh-token}
      name: oidc

그럼 kubeconfig 파일이 이런 식으로 설정될 것인데, 달리 말하자면 처음부터 파일을 이렇게 수정해도 된다는 것.
아니면, 처음부터 토큰을 받아와서 --token을 명시해도 된다.

인증 파일 구성

위에 인증 모듈들을 보면, 죄다 api 서버에 인자로 넣어주는 것을 볼 수 있다.
이 인자들을 따로 파일로 인증 모듈을 관리하는 방법이 있다!
--authentication-config 인자를 넣어주면 된다.

참고로 Kubernetes v1.32 - Penelope에서도 아직 인증 설정 파일의 구조 형식이 베타 레벨이다.
이 파일은 아직 모든 방식의 인증 모듈을 지원하지는 않는 것으로 알고 있다.
그래서 StructuredAuthenticationconfiguration 피처 게이트가 비활성화돼있으면 안 된다.
그리고 이를 위해 인자를 넣었다면, 절대 OIDC 관련 인자가 추가적으로 들어가있으면 안 된다.

이 방식의 장점은 여러 가지가 있다.
일단 CEL 표현을 이용해 유저 속성과 매핑할 수 있게 하여, 쉽게 인증 로직을 추가할 수 있다.
그리고 api 서버는 이 파일이 수정되었을 때 동적으로 인증 모듈을 리로드한다!(이게 진짜 꿀임)
apiserver_authentication_config_controller_automatic_reload_last_timestamp_seconds 메트릭을 통해 마지막으로 리로딩된 시점을 알 수 있다.

apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
# JWT 호환 토큰을 사용하는 인증기를 리스트로 최대 64개를 넣을 수 있다.
jwt:
  # 발행자가 누군지를 명시하고, 발행자가 발행할 때 토큰에 있어야 하는 값들을 설정하는 부분이다.
- issuer:
    # url은 인증기마다 고유해야 한하며, `--service-account-issuer`의 발급자와 충돌하면 안된다.
    url: https://example.com # --oidc-issuer-url과 같다.
    # 원래 제공자 탐색 요청은 "{url}/.well-known/openid-configuration" 경로로 간다.
    # 그러나 discoveryURL이 명시되면, 이를 덮어쓴다.
    # 그래서 쓸 거면 아주 정확하게 url 경로를 명시해야 한다.
    # 탐색을 통해 가져와진 issuer 필드는  여기 적힌 url 필드와 반드시 일치해야 하며, JWT의 iss 클레임을 검증하는데 사용된다.
    # 이건 잘 알려진 JWT 엔드포인트가 다른 발급자와 다른 위치에서 호스팅되는 시나리오를 위한 것이다.
    # 이 값도 고유해야 한다.
    discoveryURL: https://discovery.example.com/.well-known/openid-configuration
    # --oidc-ca-file와 같은 의미의 필드이다.
    # PEM으로 인코딩된 CA 인증서는 탐색을 하는데 사용된다.
    # 이 값이 없다면 그냥 시스템의 파일을 사용할 것이다.
    certificateAuthority: <PEM encoded CA certificates>   
    # 청중은 JWT가 반드시 발급 받아야 하는 값이다.
    # JWT 클레임의 aud가 반드시 이 값에 있어야 해당 토큰이 유효하다고 판단될 것이다.
    audiences:
    - my-app # --oidc-client-id와 같다.
    - my-other-app
    # 여러 청중이 명시되었을 때는 반드시 MatchAny를 써야 한다.
    audienceMatchPolicy: MatchAny
  # 유저를 인증하기 위해 토큰 클레임을 검증할 때 적용되는 정책
  claimValidationRules:
    # --oidc-required-claim key=value와 같으며, 이러면 hd: example.com 일 때 유효할 것이다.
  - claim: hd
    requiredValue: example.com
    # 이 방식 대신 진위를 판별하는 CEL 표현식으로 사용해도 된다.
  - expression: 'claims.hd == "example.com"'
    # 검증이 실패했을 때 api서버 로그에 나올 에러 메시지 커스텀
    message: the hd claim must be set to example.com
  - expression: 'claims.exp - claims.nbf <= 86400'
    message: total token lifetime must not exceed 24 hours
  # 클레임 매핑
  claimMappings:
    # username은 유저이름 속성을 대표하는 옵션으로, 이 필드는 필수 요구된다.
    username:
      # --oidc-username-claim와 같으며, username.expression과 양립할 수 없다.
      claim: "sub"
      # --oidc-username-prefix와 같으며 username.expression과 양립할 수 없다.
      # 위의 클레임이 설정됐을 때는 이것도 같이 있어야 하며, 아무것도 안 넣고 싶으면 ""를 넣어라.
      prefix: ""
      # CEL  표현식으로 매칭할 수 있다.
      # 1.  'claims.email'을 쓸 거라면 'claims.email_verified'가 유저네임 표현식에 들어가야한다.
      #     아니면 extra[*].valueExpression이나 claimValidationRules[*].expression이 들어가야 한다.
	  #     이건 유저네임 클레임 검증이 email로 됐을 때 자동 적용되는 CEL 표현식 예시이다.'claims.?email_verified.orValue(true)'
      # 2.  username.expression이 빈 문자열이면, 인증은 실패한다.
      expression: 'claims.username + ":external-user"'
    # 그룹 속성에 대한 옵션으로, 여기서부터는 선택
    groups:
      # --oidc-groups-claim와 같다. 위와 같이 expression과 같이 있을 수 없다.
      claim: "sub"
      prefix: ""
      # 문자열 리스트를 평가하는 표현식이어야 한다.
      expression: 'claims.roles.split(",")'
    # uid 속성에 대한 옵션
    uid:
      claim: 'sub'
      # 이것도 둘이 같이 못있는다.
      expression: 'claims.sub'
    # UserInfo 객체에 추가되는 추가 속성으로, 키는 반드시 고유한 도메인 형식이어야 한다.
    extra:
      # / 전까지는 도메인이고, 이후에는 전부 경로로 간주된다.
      # k8s.io, kubernetes.io 는 쿠버네티스에 예약되었으므로 사용할 수 없다.
      # 전부 소문자로, extra 사이에서 고유해야 한다.
    - key: 'example.com/tenant'
      # 리스트를 검증하는 표현식이면 된다.
      valueExpression: 'claims.tenant'
  # 최종 유저 객체에 적용되는 검증 규칙.
  userValidationRules:
    # 진위를 판별하는 CEL 표현이면 된다.
    # 여기에 해당하는 모든 표현식을 통과해야 유효하다고 판정된다..
  - expression: "!user.username.startsWith('system:')"
    message: 'username cannot used reserved system: prefix'
  - expression: "user.groups.all(group, !group.startsWith('system:'))"
    message: 'groups cannot used reserved system: prefix'

보다시피 다양한 설정을 할 수 있는데, 검증로직을 다양하게 할 수 있다는 것을 알 수 있다.

상세한 설정을 통해 어떤 유저의 인증이 이뤄지지 않더라도 구체적으로 왜 이뤄지지 않았는지 상세하게 표시할 수 있다.

2.인가(Authorization)

두번째 단계는 인가로, 인증된 주체가 어떤 대상에 어떤 행동을 할 수 있는지에 대한 것을 규정한다.
인가는 일반적인 REST 속성을 사용하기에, REST 형식을 잘 알고 사용하는 것이 중요하다.

{
    "apiVersion": "abac.authorization.kubernetes.io/v1beta1",
    "kind": "Policy",
    "spec": {
        "user": "bob",
        "namespace": "projectCaribou",
        "resource": "pods",
        "readonly": true
    }
}

가령 이런 식의 정책이 지정돼있다면 bob이란 유저는 projectCaribou의 파드에 읽기가 가능할 것이다.

3,4. 승인 제어(admission control)

승인 제어는 요청의 내용 자체를 변경하거나, 검증해 거부할 수 있는 소프트웨어 모듈이다.
인가 단계에서는 단순히 어떤 대상을 조작할 수 있는에 대한 권한만 다뤘다.
그러나 승인 제어는 세부 대상에 실제로 어떤 요청을 날렸는지 검토하고 조작하는 것이 가능하다.
구체적으로 승인 제어는 요청 변형(mutating), 요청 검증(validation) 가지 단계로 나뉘며, 그래서 이게 3,4 단계에 해당한다.

5. 감사(Audit)

실제 요청에 대한 제어와 허가가 이뤄지는 것은 4단계에서 끝난다.
보안에서 중요하게 다뤄야 하는 사항 중 또 하나가 바로 기록을 하는 것이다.
감사는 요청이 수행되거나 거부되거나, 하는 api 서버에 대해 이뤄지는 모든 상황을 조선왕조실록마냥 미주알고주알 적는 단계이다.
유저, 어플리케이션은 물론이고 컨트롤 플레인 내부에서 일어나는 모든 api 접근에 대해 감사가 남는다.
결국 이것도 로그 혹은 이벤트이니, 어떻게 보면 관측 가능성의 한 요소로서도 볼 수 있겠다.
자세한 내용은 쿠버네티스 감사에 담았으니 참고하자.

실습 진행

앞서 말했듯이 인가, 승인 제어 부분은 다음 글에서 진행할 것이다.
아울러, eks에서는 아쉽게도(..?) api 서버에 대한 조작을 사용자가 직접 할 수 있는 방법이 없다.
그렇기 때문에 본 실습에서는 로컬 환경에 Vagrant를 이용해 Kubernetes v1.32 - Penelope 클러스터를 구축했다.
아직 디렉토리 정리가 끝나지 않아 환경 세팅 파일은 구태여 공개하지 않겠으나.. 내 개인 레포에 들어가보면 있기는 하다.

kubectl 요청 분석

k get po -v 8

kubectl 요청을 날릴 때 -v 옵션을 주면 디버그 레벨을 조정할 수 있다.
image.png

GET https://192.168.80.11:6443/api/v1/namespaces/default/pods?limit=500

중간에 이러한 구문이 보인다.
kubectl은 사실 그저 api서버에 http 요청을 보내고 있다는 것을 알 수 있는 대목이다.
위에서 미리 구조를 살펴봤듯이, {api서버 주소}/api/v1/{리소스 유형}의 구조를 가진다는 것을 알 수 있다.
이때 네임스페이스에 종속되는 리소스의 경우 리소스 유형 앞에 네임스페이스를 지정하는 것도 가능하다.

k get ns -v 8

image.png
말 나온 김에 네임스페이스도 한번 보자면...

GET https://192.168.80.11:6443/api/v1/namespaces?limit=500

요청이 이런 식으로 간다.

GET https://192.168.80.11:6443/api/v1/namespaces/default

만약 특정 네임스페이스에 대해서 지정을 해서 요청을 날린다면 이런 식으로 요청이 간다.
복수형으로 쓰여진 리소스 유형 뒤에 특정 리소스가 명시돼 요청이 날아가는 것이다.

{
    "apiVersion": "v1",
    "items": [
        {
            "apiVersion": "v1",
            "kind": "Pod",
            "metadata": {
	            "...": "...",
                "name": "test",
                "namespace": "default",
            },
            "spec": {
                "containers": [
	                ..
                ],
            },
            "status": {
            }
        }
    ],
    "kind": "List",
    "metadata": {
        "resourceVersion": ""
    }
}

k get po -ojson과 같이 리스트를 불러오는 호출을 하게 되면 실제 응답은 여러 리소스를 List 객체로 묶어서 반환한다는 것을 확인할 수 있다.

인증 - Keycloak을 통한 OIDC 토큰

인증의 다양한 방법 중 OIDC를 이용한 인증 방법을 간단하게 실습하고자 한다.
근데 사실 간단하진 않은 것 같다 ㅋ
클라우드에서 제공하는 IDP를 이용하는 방법도 있지만, 여기에서는 클러스터 환경에 Keycloak을 간단하게 설치하여 활용해보고자 한다.
(Keycloak은 조금 더 정리하여 사전 지식 부분에 내용을 추가하겠다.)

사전 세팅

kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.31/deploy/local-path-storage.yaml

키클록에 사용되는 postgre 기동을 위해, 간단하게 로컬 패스 프로비저너를 사용한다.

 storageclass.kubernetes.io/is-default-class: "true"

자동으로 생긴 스토리지 클래스가 디폴트가 되도록 어노테이션을 달아줬다.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm install  keycloak bitnami/keycloak -n keycloak --create-namespace -f keycloak-helm.yaml

헬름으로 bitnami에서 키클록을 받아 설치를 진행한다.

auth:
  adminUser: admin
  adminPassword: "admin"
tls:
  enabled: true
  autoGenerated: true
adminRealm: "master"
production: false
httpRelativePath: "/"
service:
  type: NodePort
  http:
    enabled: true
  externalTrafficPolicy: Cluster
ingress:
  enabled: false
adminIngress:
  enabled: false

keycloakConfigCli:
  enabled: false
postgresql:
  enabled: true
  auth:
    postgresPassword: ""
    username: bn_keycloak
    password: ""
    database: bitnami_keycloak
    secretKeys:
      userPasswordKey: password
  architecture: standalone

values.yaml 파일은 대충 이 정도로만 세팅해줬다.
어드민 계정 설정과 tls 인증서, 노드포트 서비스로 만들어주었다.
image.png
간단하게 테스트하라고 설치 후에 포트포워딩을 어떻게 하면 되는지까지 친절하게 알려준다.

k -n keycloak get secrets keycloak-crt -oyaml

이때 생긴 tls 인증서는 자체 서명된 임의의 CA로부터 받은 인증서이기에, 나중에 api 서버를 설정할 때 활용해야 한다.
image.png
이로부터 생기는 시크릿 파일의 ca 인증서를 미리 저장해두자.

기본 세팅

참고로 나는 키클록에 대해서는 OIDC 인증 제공자로서 활용할 수 있을 정도로만 간략하게 공부했다.
세팅 역시 다른 분의 글을 상당히 많이 참고했다.[2]
Pasted image 20250129195534.png
내 realm을 먼저 만든다.
Pasted image 20250129195723.png
한 realm 안에는 여러 클라이언트를 둘 수 있는데, 이 클라이언트가 각각 인증을 수행하는 큰 단위가 된다.
키클록을 통해 여러 서비스의 인증을 수행하고 싶다면 여러 클라이언트를 만드는 식으로 세팅하면 되는 것이다.
그래서 이 클라이언트를 이제 만들어준다.
기본으로 존재하는 클라도 상당히 많은 것이 보인다.

OIDC로 사용하기 위해서는 Client Authentication을 활성화해줘야 한다.
여기에서 direct access grants가 있으면 비밀번호로 토큰을 받을 수 있다.
image.png
만들어지면 바로 credential에서 client secret 값을 메모해둔다.
이 값은 나중에 이 클라이언트를 이용해 토큰을 발급받을 때 사용하게 될 것이다.
image.png
그 다음에는 바로 옆 탭 roles를 들어가 롤을 하나 만든다.

유저 세팅

image.png
다음은 유저를 하나 만든다.
여기에서 username만 필수라고 해서 다른 부분을 세팅하지 않으면 유저가 완전히 세팅되지 않았다는 에러를 보게 되므로, 미리 세팅을 해두자.
image.png
비번도 만들어준다.
image.png
이후에 위에서 만든 롤이 처음 만든 scope 속에 들어있는 것을 확인할 수 있다.
이걸 바로 붙여준다.

ID 토큰 확인

curl -k -X POST https://{키클록 주소}/realms/{realm 이름}/protocol/openid-connect/token \
-d grant_type=password -d client_id={클라이언트 이름} -d username={유저 이름} -d password={비번} -d \
scope=openid -d client_secret={클라 시크릿}  | jq -r '.id_token'

이렇게 해서 나와준다면 성공이다!
image.png
값을 jwt.io에 넣어보면, 내 유저의 값이 정상적으로 나오는 것을 확인할 수 있다.

api 서버 OIDC 세팅

api 서버에 인자를 넣어서 설정하는 방법도 좋지만, 세밀하게 설정을 제어하기 위해서는 설정 파일을 사용하는 것이 가장 좋으므로, 해당 방법을 사용한다.
image.png
파일의 경로를 이런 식으로 추가해준다.
실습을 간단히 하기 위해 이미 마운팅된 호스트 노드 경로에 파일을 위치시켰다.

apiVersion: apiserver.config.k8s.io/v1beta1
kind: AuthenticationConfiguration
jwt:
- issuer:
    url: https://keycloak.keycloak/realms/test-realm
    audiences:
    - kubernetes-auth
    audienceMatchPolicy: MatchAny
    certificateAuthority: |-
      -----BEGIN CERTIFICATE-----
      키클록 설치할 때 받은 ca.crt를 base64로 디코딩해서 넣자
      -----END CERTIFICATE-----
  claimValidationRules:
  - expression: 'claims.iss.contains("keycloak")'
    message: Issuer expected Keycloak!!!
  claimMappings:
    username:
      claim: "preferred_username"
      prefix: ""
  userValidationRules:
  - expression: "!user.username.startsWith('system:')"
    message: 'username cannot used reserved system: prefix'

간단하게 이런 식으로 작성을 해주었다.
검증 규칙은 CEL 방식인데, 이건 기회가 되면 다루겠다.
필수적으로 세팅이 돼야 하는 것은 이슈어 부분에 url, audiences.
대충 설명하자면 이슈어 문자열에 키클록이 들어가지 않으면 토큰 자체가 검증에 실패하고, 인증될 때 사용될 유저 이름은 preferred_username 클레임을 사용한다.
유저 이름이 system으로 시작하면 유저 검증 실패를 하도록 만들었다.
실제로 이렇게 세팅된 값은 api 서버의 로그로 남게 된다.
(audit에 남는 것이 아님을 유의하자)

세팅 시 유의할 점 중 하나는 클레임 검증 규칙에서 표현식의 대상이 되는 값과 클레임 매핑시 사용되는 값, 유저 검증 규칙에 사용되는 값이 전부 다르다는 것이다.
클레임 검증 시에는 claims를 앞단에 쓸 수 있지만 클레임 매핑부터는 한 깊이를 들어가서 값에 접근한다.
유저 검증 규칙에서는 매핑된 값을 토대로 user 객체가 생성되어 이것을 활용해야 한다.

추가적인 세팅

여기에서 몇 가지 꼼수가 필요하다..
이전에 세팅한 것들을 살짝 날려먹어서 어쩔 수 없이 돌려막기식 세팅을 한 것들이 있는데 조금 더 잘 정제된 세팅 환경에서는 이렇게까지 불편한 세팅을 할 필요는 없을 것이다.

이미 로드밸런서 컨트롤러를 구축해둔 환경에서는 이렇게까지 귀찮게 세팅을 할 필요가 없을 것이다.
또 클러스터 도메인을 노드에서도 활용할 수 있게 도와주는 툴을 활용하고 있다면 이렇게 도메인 세팅을 귀찮게 할 필요도 없어진다.

최종 테스트

kube_url = "https://192.168.80.11:6443/api/v1/namespaces/default/pods/debug?limit=500"

def request_in_oidc():
    url = 'https://keycloak.keycloak/realms/test-realm/protocol/openid-connect/token'
    body = {
        'grant_type': 'password',
        'client_id': 'kubernetes-auth',
        'client_secret': 'f4n2pYSsjOr3Epfh04ASwHPCU9tIrXXD',
        'username': 'zerotay',
        'password': '1234',
        'scope': 'openid'
    }
    response = requests.post(url=url,data=body, verify="")
    id_token:str = response.json()["id_token"]

    print("\nThis is IdToken", id_token[:20],"...")
    headers = {
        "Content-Type": "application/json",
        "Authorization" : "Bearer " + id_token.strip()
    }
    response = requests.get( url= kube_url, headers=headers, verify="",)
    return response

def main(args: argparse.Namespace):
    type: AuthType = args.type
    print("Test for ", type)
    response = requests.models.Response()
    match type:
        case AuthType.oidc:
            response = request_in_oidc()

    print(response.status_code)
    print('Response Header :::::')
    print(json.dumps(dict(response.headers), indent=2))
    print('Response Body :::::')
    print(json.dumps(response.json(), indent=2))

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="put subcommand")
    parser.add_argument("--type", type=AuthType, required=True, help="x509, static, bootstrap, webhook, oidc, satoken")
    args = parser.parse_args()
    main(args)

이건 내가 흔히 테스트를 할 때 사용하려고 만든 코드의 일부이다.

python3 test.py --type oidc

위의 세팅을 기반으로 이 파일을 실행하면..
image.png
이런 식으로 403 에러와 함께 실패하면 성공이다!
인증이 되지 않았다면 401에러가 났을 것이고, 현재 에러는 인가되지 않음을 뜻하는 403 UnAuthorized이다.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  annotations:
    rbac.authorization.kubernetes.io/autoupdate: "true"
  labels:
    kubernetes.io/bootstrapping: rbac-defaults
  name: test-admin-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: admin
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: User
  name: zerotay

기왕이면 제대로 결과를 보고 싶으니, 간단하게 이렇게 인가 설정을 해준다.
image.png
이제는 결과가 제대로 나오는 것이 확인된다.

결론

api 구조를 알고 있으면 기본적인 리소스의 정보를 얻는 방법, 양식을 작성하는 방법 등에서 다양한 도움을 얻을 수 있다.
쿠버네티스 인증의 다양한 방법 중, OIDC를 이용한 방법은 jwt 토큰을 이용한다.
페이로드 부분에 대해 상세한 정책을 설정하여 인증 책임을 외부에 넘기는 한편 api 서버 단에서도 인증에 대한 추가적인 검증을 할 수 있기 때문에, 운영 조직에 따라서는 엄격한 보안 환경을 구현하는데 활용할 수 있을 것이다.
세팅을 해야 하는 환경이 많아지는 것은 운영의 어려움을 야기하지만, 여건에 따라서는 항상 안전을 기하는 것이 중요할 수도 있을 것이라 생각한다.

이전 글, 다음 글

다른 글 보기

이름 index noteType created
1W - EKS 설치 및 액세스 엔드포인트 변경 실습 1 published 2025-02-03
2W - 테라폼으로 환경 구성 및 VPC 연결 2 published 2025-02-11
2W - EKS VPC CNI 분석 3 published 2025-02-11
2W - ALB Controller, External DNS 4 published 2025-02-15
3W - kubestr과 EBS CSI 드라이버 5 published 2025-02-21
3W - EFS 드라이버, 인스턴스 스토어 활용 6 published 2025-02-22
4W - 번외 AL2023 노드 초기화 커스텀 7 published 2025-02-25
4W - EKS 모니터링과 관측 가능성 8 published 2025-02-28
4W - 프로메테우스 스택을 통한 EKS 모니터링 9 published 2025-02-28
5W - HPA, KEDA를 활용한 파드 오토스케일링 10 published 2025-03-07
5W - Karpenter를 활용한 클러스터 오토스케일링 11 published 2025-03-07
6W - PKI 구조, CSR 리소스를 통한 api 서버 조회 12 published 2025-03-15
6W - api 구조와 보안 1 - 인증 13 published 2025-03-15
6W - api 보안 2 - 인가, 어드미션 제어 14 published 2025-03-16
6W - EKS 파드에서 AWS 리소스 접근 제어 15 published 2025-03-16
6W - EKS api 서버 접근 보안 16 published 2025-03-16
7W - 쿠버네티스의 스케줄링, 커스텀 스케줄러 설정 17 published 2025-03-22
7W - EKS Fargate 18 published 2025-03-22
7W - EKS Automode 19 published 2025-03-22
8W - 아르고 워크플로우 20 published 2025-03-30
8W - 아르고 롤아웃 21 published 2025-03-30
8W - 아르고 CD 22 published 2025-03-30
8W - CICD 23 published 2025-03-30
9W - EKS 업그레이드 24 published 2025-04-02
10W - Vault를 활용한 CICD 보안 25 published 2025-04-16
11W - EKS에서 FSx, Inferentia 활용하기 26 published 2025-04-18
11주차 - EKS에서 FSx, Inferentia 활용하기 26 published 2025-05-11
12W - VPC Lattice 기반 gateway api 27 published 2025-04-27

관련 문서

이름 noteType created
API 접근 제어 우회 knowledge 2025-01-13
Authentication knowledge 2025-01-13
Authorization knowledge 2025-01-19
Prometheus-Adapter knowledge 2025-03-04
kube-apiserver knowledge 2025-03-12
쿠버네티스 API 구조 knowledge 2025-03-19
E-쿠버네티스 인증 실습 topic/explain 2025-01-21
E-api 서버 감사 topic/explain 2025-01-21
T-서비스 어카운트 토큰은 어떻게 인증되는가 topic/temp 2025-03-16

참고


  1. https://kubernetes.io/docs/concepts/overview/working-with-objects/ ↩︎

  2. https://wlsdn3004.tistory.com/62 ↩︎