Service Account Token Volume Projection

쿠버네티스(kubernetes)에는 서비스 계정(service account)이라는 것이 있습니다. 서비스 계정은 롤(role)과 클러스터롤(clusterrole)을 바인딩하여 권한을 부여하고, 쿠버네티스 api를 호출하는데 사용합니다.

서비스 계정은 토큰(token)이라는 것을 가지고 있고, 이 토큰을 통해서 자격 증명을 얻습니다. 쿠버네티스 api를 호출할 때, 토큰을 같이 전송합니다. api 서버에서는 토큰값이 유효한지 판단한 다음, 서비스 계정 이름을 얻어내어, 부여된 권한들 검사하며, 요청한 api에 권한이 있는지 없는지 판단합니다.

그렇다면, 서비스 계정 토큰을 이용해서 서비스와 서비스, 즉 포드(pod)와 포드(pod)의 호출에서 자격 증명으로 사용할 수 있을까요? 불행히도 기본 서비스 계정 토큰으로는 사용하기에 부족함이 있습니다. 토큰을 사용하는 대상(audience), 유효 기간 등 토큰의 속성을 지정할 필요가 있기 때문입니다. Service Account Token Volume Projection 기능을 사용하면 이러한 부족한 점들을 해결할 수 있습니다.

Service Account Token Volume Projection 기능은 쿠버네티스 1.10에서 알파 버전으로 도입 되었습니다. 1.12에서 베타 상태로 전환 되었고, 현재 최신 버전인 1.17에서도 베타 상태입니다.

기능 활성화

이 기능을 사용하기 위해서는, 아래와 같이 kube-apiserver 매니페스트에 몇 가지 플래그를 추가해야합니다. 일반적으로 매니페스트 파일은 /etc/kubernetes/manifests/kube-apiserver.yaml에 위치합니다.

    - --service-account-signing-key-file=/etc/kubernetes/pki/sa.key
    - --service-account-issuer=api
    - --service-account-api-audiences=api,vault

매니페스트 파일에 추가되는 플래그를 살펴 보겠습니다.

  • service-account-signing-key-file : 서비스 계정 토큰 발급자(issuer)의 개인키 파일 경로입니다. 발급자늘 이 개인키로 토큰을 서명합니다.
  • service-account-issuer : 서비스 계정 토큰 발급자의 식별자입니다. 이 값은 문자열이나 URI를 사용할 수 있습니다.
  • service-account-api-audiences : 토큰이 사용되는 대상을 나타냅니다. 토큰을 사용할때 정의된 대상 중에 속해있는지 확인합니다.

TokenRequest API 와 TokenReview API

전체적인 흐름을 파악하기 위해서, TokenRequest API와, TokenReview API에 대해서 알아보도록 하겠습니다.

다음 그림은 전체 흐름을 표현한 것입니다.

  1. 포드 A 에서 TokenRequest API를 호출해서 토큰을 생성을 요청합니다.
  2. 요청을 받은 kube-api-server는 토큰을 생성해서 포드 A에게 돌려줍니다.
  3. 포드 A 는 포드 B로 API 호출을 합니다. 이때 생성된 토큰을 요청에 포함시킵니다.
  4. 요청을 받은 포드 B는 토큰 정보를 추출하고, TokenReview API를 호출합니다.
  5. kube-api-server는 토큰의 유효성 같은 속성 정보를 포드 B에게 돌려줍니다.
  6. 토큰에 문제가 없다면 API 호출에 대한 응답을 합니다.

서비스 계정 생성과 권한 부여

TokenRequest API와 TokenReview API를 직접 사용해 보도록 하겠습니다. 해당 API들을 사용하기 위해서는 특정 리소스 권한이 필요합니다. 예제에서는 편의를 위해서 cluster-admin 클러스터롤을 부여하도록 하겠습니다.

테스트를 위해서 token-admin 네임스페이스를 생성하고, token-admin 서비스 계정을 생성합니다.

$ kubectl create namespace token-test
namespace/token-test created

$ kubectl -n token-test create serviceaccount token-admin
serviceaccount/token-admin created

token-admin 서비스계정에 `cluster-admin` 클러스터롤을 부여합니다.

$ kubectl create clusterrolebinding token-admin --clusterrole=cluster-admin --serviceaccount=token-test:token-admin

clusterrolebinding.rbac.authorization.k8s.io/token-admin created

생성한 서비스 계정의 토큰 정보를 조회합니다.

$ kubectl -n token-test describe secret $(kubectl -n token-test get sa token-admin -o json | jq -r '.secrets[].name')
Name:         token-admin-token-r6h8z
Namespace:    token-test
Labels:       <none>
Annotations:  kubernetes.io/service-account.name: token-admin
              kubernetes.io/service-account.uid: 1357fefc-59a4-474c-bbfa-b7567b87b50f

Type:  kubernetes.io/service-account-token

Data
====
namespace:  10 bytes
token:      eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJ0b2tlbi10ZXN0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWN...
ca.crt:     1143 bytes

eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9. 로 시작하는 부분이 토큰 입니다. 서비스 계정의 기본 토큰입니다. JWT이기 때문에 jwt.io에서 토큰 페이로드를 조회해 볼 수 있습니다.

TokenRequest API를 호출해서 토큰 생성하기

TokenRequest API를 이용해서, 포드 B를 호출할 때 넘겨 줄 토큰을 생성해보겠습니다. 위 그림에서 1, 2 단계에 해당됩니다.

다음은 TokenRequest API를 호출하는 curl 템플릿입니다.

$ curl -k -X "POST" "https://{KUB_API_SERVER}/api/v1/namespaces/{NAMESPACE}/serviceaccounts/{SERVICEACCOUNT}/token" \
     -H 'Authorization: Bearer {TOKEN}' \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{}'
  • {KUB_API_SERVER} : 쿠버네티스 API 서버의 주소입니다. 자신의 쿠버네티스 주소를 입력합니다.
  • {NAMESPACE} : 네임스페이스입니다. 예제에서는 token-test 입니다.
  • {SERVICEACCOUNT} : 서비스 계정입니다. 예제에서는 token-admin 입니다.
  • {TOKEN} : 서비스 계정의 기본 토큰입니다. 앞서 secret에서 조회한 토큰을 입력합니다.

다음과 같이 TokenRequest API를 호출해 보겠습니다.

$ curl -k -X "POST" "https://192.168.21.31:6443/api/v1/namespaces/token-test/serviceaccounts/token-admin/token" \
     -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJ0b2tlbi10ZXN0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWN...' \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{}'

정상적으로 호출 되었다면, 다음과 같은 응답을 얻을 수 있습니다.

{
  "kind": "TokenRequest",
  "apiVersion": "authentication.k8s.io/v1",
  "metadata": {
    "selfLink": "/api/v1/namespaces/token-test/serviceaccounts/token-admin/token",
    "creationTimestamp": null
  },
  "spec": {
    "audiences": [
      "api",
      "vault"
    ],
    "expirationSeconds": 3600,
    "boundObjectRef": null
  },
  "status": {
    "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJhdWQiOlsiYXBpIiwidmF1bHQiLCJ...",
    "expirationTimestamp": "2020-02-12T13:55:51Z"
  }
}

eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJhdWQiOlsiYXBpIiwidmF1bHQiLCJ… 라고 적혀 있는 부분이 바로 새로 생성된 토큰입니다. 기존 토큰과 구별하기 위해서 BOUND-TOKEN 이라고 부르겠습니다. 포드 A가 포드 B를 호출할 때 BOUND-TOKEN 토큰을 사용하게 됩니다.

TokenReview API를 호출해서 토큰 확인하기

TokenReview API를 호출하여, 토큰을 확인해 보겠습니다. 위 그림에서 4, 5 단계에 해당됩니다.

다음은 TokenRequest API를 호출하는 curl 템플릿입니다.

curl -X "POST" "https://{KUB_API_SERVER}/apis/authentication.k8s.io/v1/tokenreviews" \
     -H 'Authorization: Bearer {TOKEN}' \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "kind": "TokenReview",
  "apiVersion": "authentication.k8s.io/v1",
  "spec": {
    "token": "{BOUND-TOKEN}"
  }
}'
  • {KUB_API_SERVER} : 쿠버네티스 API 서버의 주소입니다. 자신의 쿠버네티스 주소를 입력합니다.
  • {TOKEN} : 서비스 계정의 기본 토큰입니다. 앞서 secret에서 조회한 토큰을 입력합니다.
  • {BOUND-TOKEN} : TokenRequest API를 호출해서 생성한 토큰입니다.

다음과 같이 TokenReview API를 호출해 보겠습니다.

curl -k -X "POST" "https://192.168.21.31:6443/apis/authentication.k8s.io/v1/tokenreviews" \
     -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJpc3MiOiJrdWJlcm5ldGVzL3NlcnZpY2VhY2NvdW50Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWNlYWNjb3VudC9uYW1lc3BhY2UiOiJ0b2tlbi10ZXN0Iiwia3ViZXJuZXRlcy5pby9zZXJ2aWN...' \
     -H 'Content-Type: application/json; charset=utf-8' \
     -d $'{
  "kind": "TokenReview",
  "apiVersion": "authentication.k8s.io/v1",
  "spec": {
    "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJhdWQiOlsiYXBpIiwidmF1bHQiLCJ..."
  }
}'

정상적으로 호출 되었다면, 다음과 같은 응답을 얻을 수 있습니다.

{
  "kind": "TokenReview",
  "apiVersion": "authentication.k8s.io/v1",
  "metadata": {
    "creationTimestamp": null
  },
  "spec": {
    "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IiJ9.eyJhdWQiOlsiYXBpIiwidmF1bHQiLCJ..."
  },
  "status": {
    "authenticated": true,
    "user": {
      "username": "system:serviceaccount:token-test:token-client",
      "uid": "b5132c47-041a-4d18-984a-02865edc1b1b",
      "groups": [
        "system:serviceaccounts",
        "system:serviceaccounts:token-test",
        "system:authenticated"
      ]
    },
    "audiences": [
      "api",
      "vault"
    ]
  }
}

포드 B 에서는 응답 결과의 status 위치에 있는 여러 값들로, 토큰이 유효한지 확인 할 수 있습니다. status.authenticated 키는 인증된 토큰인지를 확인시켜줍니다. 그리고 status.audiences 는 토큰이 사용 될 수 있는 대상이 나열되어 있습니다. 토큰의 사용 대상이 맞는지 여부를 확인하는 것은 개발자의 몫입니다.

Service Account Token Volume Projection 사용하기

TokenRequest API를 직접 사용하지 않고, Service Account Token Volume Projection 을 사용하겠습니다. 예제에 사용한 매니페스트와 애플리케이션 소스는 https://github.com/kangwoo/projected-svc-token 에서 확인할 수 있습니다.

token-client 서비스 계정을 만듭니다.

$ kubectl -n token-test create serviceaccount token-client
serviceaccount/token-client created

token-client 애플리케이션을 실행하는 포드 리소스를 생성합니다. Service Account Token Volume Projection 을 사용기 위해서 spec.volumes 부분에 projected를 사용하였습니다. 이 부분 때문에 토큰이 자동으로 생성됩니다.

다음은 token-client 포드 매니페스트 입니다.

apiVersion: v1
kind: Pod
metadata:
  name: token-client
spec:
  containers:
  - image: kangwoo/token-client
    name: token-client
    ports:
    - containerPort: 8090
    volumeMounts:
    - mountPath: /var/run/secrets/tokens
      name: vault-token
  serviceAccountName: token-client
  volumes:
  - name: vault-token
    projected:
      sources:
      - serviceAccountToken:
          path: vault-token
          expirationSeconds: 7200
          audience: vault

token-client 포드를 생성했다면, 다음과 같이 서비스 계정 볼륨 프로젝션이 작동하는지 확인 할 수 있습니다.

$ kubectl -n kube-test  exec -it token-client cat /var/run/secrets/tokens/vault-token

토큰을 사용하는 token-server 를 설치하겠습니다. token-server 도 서비스 계정이 필요합니다. 그리고 TokenReview API에 접근할 수 있도록 롤을 바인딩해줘야합니다.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: token-server
  namespace: token-test

---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: tokenreview-binding-token-server
  namespace: token-test
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: system:auth-delegator
subjects:
  - kind: ServiceAccount
    name: token-server
    namespace: token-test

그리고 디플로이먼트(deployment) 매니페스트와, 서비스(service) 매니페스트를 이용해서 token-server 애플리케이션을 구동합니다.

---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: token-server
  name: token-server
  namespace: token-test
spec:
  replicas: 1
  selector:
    matchLabels:
      app: token-server
  strategy:
    rollingUpdate:
      maxUnavailable: 1
  template:
    metadata:
      labels:
        app: token-server
    spec:
      serviceAccountName: token-server
      containers:
        - image: kangwoo/token-server:0.0.1
          name: token-server
          ports:
            - containerPort: 8090
          resources:
            limits:
              cpu: 100m
              memory: 64Mi
            requests:
              cpu: 100m
              memory: 64Mi


---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: token-server
  name: token-server
  namespace: token-test
spec:
  ports:
    - name: http
      port: 8090
  selector:
    app: token-server

token-client와 token-server의 설치가 완료되었으면, token-client를 호출해 봅니다.

$ curl http://localhost:8090

정상적으로 작동 한다면, 다음과 같은 메시지를 볼 수 있습니다.

Hello, This is token-server

curltoken-client를 호출하면, token-client/var/run/secrets/tokens/vault-token 에서 토큰을 읽어옵니다. 그리고 해당 토큰을 헤더에 넣어서 token-server에게 요청을 보냅니다.

다음 token-client 애플리케이션 코드의 일부입니다.

package main

import (
	"io/ioutil"
	"log"
	"net/http"
	"os"
)


func readToken() []byte {
	file, err := os.Open("/var/run/secrets/tokens/vault-token")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	bytes, err := ioutil.ReadAll(file)
	if err != nil {
		log.Fatal(err)
	}
	return bytes
}

func requestWithToken(w http.ResponseWriter, r *http.Request) {
	token := readToken()

	client := &http.Client{}
	req, _ := http.NewRequest("GET", "http://token-server:8090", nil)
	req.Header.Set("X-Auth-Token", string(token))
	resp, err := client.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusForbidden {
		w.Write([]byte("403 : StatusForbidden"))
		return
	} else if resp.StatusCode == http.StatusOK {
		body, _ := ioutil.ReadAll(resp.Body)
		w.Write(body)
	}

}

func main()  {
	log.Println("Starting token-client")

	http.HandleFunc("/", requestWithToken)

	http.ListenAndServe(":8090", nil)
}

token-sever는 요청을 받아서, 헤더값을 토큰을 추출합니다. 그리고 TokenReview API를 이용해서 토큰의 유효성을 체크하고, 문제가 없다면 “Hello, This is token-server” 라는 메시지를 응답으로 보내줍니다.

다음은 token-server 애플리케이션 코드의 일부입니다.

package main

import (
	"bytes"
	"crypto/tls"
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
	"os"
)


const audience = "vault"

func readServiceAccountToken() []byte {
	file, err := os.Open("/run/secrets/kubernetes.io/serviceaccount/token")
	if err != nil {
		log.Fatal(err)
	}
	defer file.Close()

	byes, err := ioutil.ReadAll(file)
	if err != nil {
		log.Fatal(err)
	}
	return byes
}

func validateToken(boundToken, serviceAccountTOken string) bool {

	reviewPayload := []byte(`{"kind": "TokenReview","apiVersion": "authentication.k8s.io/v1","spec": {"token": "` + serviceAccountTOken + `"}}`)
	body := bytes.NewBuffer(reviewPayload)

	tr := &http.Transport{
		TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
	}
	client := &http.Client{Transport: tr}

	req, err := http.NewRequest("POST", "https://kubernetes.default:443/apis/authentication.k8s.io/v1/tokenreviews", body)

	req.Header.Add("Authorization", "Bearer " +boundToken)
	req.Header.Add("Content-Type", "application/json; charset=utf-8")

	resp, err := client.Do(req)
	if err != nil {
		log.Printf("Failure : %s", err)
	}
	defer resp.Body.Close()

	respBody, _ := ioutil.ReadAll(resp.Body)

	var respData map[string]interface{}
	if err := json.Unmarshal(respBody, &respData); err != nil {
		log.Printf("Error unmarshaling response %s", err)
	}

	if respData["status"].(map[string]interface{})["authenticated"] == true {

		if validateAudiences(respData["status"].(map[string]interface{})["audiences"].([]interface{})) {
			return true
		} else {
			log.Printf("Audience validation failed.")
		}

	} else {
		log.Printf("Authenticated failed.")
	}

	return false
}

func validateAudiences(audiences []interface{}) bool {
	for _, v := range audiences {
		if v == audience {
			return true
		}
		continue
	}

	return false
}

func requestHandler(w http.ResponseWriter, r *http.Request) {
	svcAcctToken := readServiceAccountToken()
	if validateToken(string(svcAcctToken), r.Header.Get("X-Auth-Token")) != true {
		w.WriteHeader(403)
		return
	}

	w.Write([]byte("Hello, This is token-server"))

}

func main()  {
	log.Println("Starting token-server")

	http.HandleFunc("/", requestHandler)

	http.ListenAndServe(":8090", nil)

정리

Service Account Token Volume Projection 을 사용하면, 포드를 띄울때, 서비스 계정의 토큰을 자동으로 생성할 수 있습니다. 이 토큰은 서비스 계정의 기본 토큰과는 다르게 여러가지 속성 정보를 가지고 있습니다. 이 때 생성한 토큰을 포드와 포드의 호출에서 자격증명으로 사용할 수 있습니다.

참고

댓글 남기기

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다