kubelet은 kube-apiserver에 api 요청을 통해

서비스어카운트토큰(serviceAccountToken)을 받아온다. 

지극히 정상적인 동작이다.

 

그러나 대규모의 클러스터를 운영시엔 이런 간단한 동작도 그리 만만하지 않다.

문제는 이렇게 발생한다.

 

A는 거대한 쿠버네티스 클러스터를 운영한다. A는 서비스 어카운트를 지니는 파드 수천개를 배포해야 한다.

대부분의 경우 여기서는 한번에 수천개의 파드(pod)를 배포하게 된다. 파드의 라이프사이클을 대부분 동일하게 가져간다는 말이다.

그리고 토큰은 기본값 1시간의 만료 시간이 존재한다.

 

서비스가 배포되고 1시간이 지났다. 수천개의 파드는 kube-apiserver에 동시에 토큰을 요청한다.

단순히 보아도 문제가 발생할 걸 알 수 있다.

 

물론 쿠버네티스가 그렇게 허술하게 설계되지는 않았다.

토큰 유효기간이 20% 남았을 때 10초의 jitter값으로 요청에 변동성을 부여한다.

pkg/kubelet/token/token_manager.go

// requiresRefresh returns true if the token is older than 80% of its total
// ttl, or if the token is older than 24 hours.
func (m *Manager) requiresRefresh(tr *authenticationv1.TokenRequest) bool {
	if tr.Spec.ExpirationSeconds == nil {
		cpy := tr.DeepCopy()
		cpy.Status.Token = ""
		klog.ErrorS(nil, "Expiration seconds was nil for token request", "tokenRequest", cpy)
		return false
	}
	now := m.clock.Now()
	exp := tr.Status.ExpirationTimestamp.Time
	iat := exp.Add(-1 * time.Duration(*tr.Spec.ExpirationSeconds) * time.Second)

	jitter := time.Duration(rand.Float64()*maxJitter.Seconds()) * time.Second
	if now.After(iat.Add(maxTTL - jitter)) {
		return true
	}
	// Require a refresh if within 20% of the TTL plus a jitter from the expiration time.
	if now.After(exp.Add(-1*time.Duration((*tr.Spec.ExpirationSeconds*20)/100)*time.Second - jitter)) {
		return true
	}
	return false
}

 

그러나 10초라는 접근법은 썩 효과적이지 않다.

실제 클러스터의 serviceaccount/token API 트래픽을 보면 알 수 있다.

serviceaccount/token API 트래픽 지표

 

이를 조정하는 것은 그리 어렵지 않다. 아래와 같은 접근법을 생각해볼 수 있다.

첫째. 선언되어있는 maxJitter값을 조정한다.

둘째. 동적인 jitter 할당이 가능하도록 한다.

 

두 접근법 다 현재보다는 유효한 부하 분산을 이뤄낼 수 있겠지만

첫 번째 방법의 경우는 token TTL을 설정할 수 있는 쿠버네티스 특성상 적정한 값을 찾기 어렵다.

 

때문에 두 번째 방법을 택했다.

코드 변경은 어렵지 않다. jitter 랜덤값의 범위를 정하던 maxJitter (기존엔 상수로 선언되어있었다) 값을

Token TTL의 10% 혹은 5분 중 짧은 시간으로 설정했다.

	// maxJitter is set to the smaller of 10% of the token's lifetime or 5 minutes.
	maxJitter := float64(*tr.Spec.ExpirationSeconds) / 10
	if maxJitter > 300 {
		maxJitter = 300
	}

	jitter := time.Duration(rand.Float64()*maxJitter) * time.Second

 

처음엔 큰 생각 없이 Token TTL의 15%로 정했지만, 긴 수명 주기의 토큰의 경우

수 시간 ~ 수 일치 유효기간을 날려먹을 수 있다는 리뷰를 받고 이와 같이 수정했다.

 

테스트의 시간이다.

api server를 관측 가능한 클러스터 환경에서 아래 manifest를 배포했다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: token-renewal-load-test
spec:
  replicas: 50  
  selector:
    matchLabels:
      app: token-renewal-load-test
  template:
    metadata:
      labels:
        app: token-renewal-load-test
    spec:
      containers:
      - name: renewal-tester
        image: busybox
        command: ["/bin/sh", "-c"]
        args:
          - |
            while true; do
              TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
              curl -s -o /dev/null -H "Authorization: Bearer $TOKEN" https://kubernetes.default.svc/api --insecure
              echo "Token used at $(date)"
              sleep 10;  
            done
        volumeMounts:
        - name: token-volume
          mountPath: /var/run/secrets/kubernetes.io/serviceaccount
      restartPolicy: Always
      serviceAccountName: default  
      volumes:
      - name: token-volume
        projected:
          sources:
          - serviceAccountToken:
              path: token  
              expirationSeconds: 600

 

Token TTL(expirationSeconds)를 10분으로 설정하고, replica를 50개로 배포하는 것이다.

개발환경은 개인용 Linux Machine + Kind 환경이었기에 50개정도로 수행했다.

 

테스트 결과는 아래와 같다.

파랑 점선 좌측이 개선된 버전의 kubelet이고, 이후가 기존 Kubelet이다.

MaxJitter 동적 할당 VS 기존 로직

 

CPU 사용률의 경우에는 변경 후 빌드한 코드에 비해 기존 코드가 최대 약 300% 이상

Token Request의 경우에는 1000%가량 높아 스파이크성 트래픽을 효과적으로 제거했다.

 

이 작업을 하던 때가 훈련소 일정이랑 겹쳐 PR을 던져두고 갔는데

10개월이 지난 현재까지 반영되지 않고있다.

 

인내해보자. 

 

참조

https://kubernetes.io/ko/docs/reference/access-authn-authz/service-accounts-admin/

https://github.com/kubernetes/kubernetes/issues/124646

https://github.com/kubernetes/kubernetes/pull/127227

+ Recent posts