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

참고

쿠버네티스 이벤트를 이용해서 오퍼레이터의 오류를 출력하기

흔히 오퍼레이터(Operator)라고 많이 부르는, 컨트롤러(Controller)에서 오류를 효과적으로 출력하는 방법에 대해서 알아보겠습니다. 이 글에서는 오퍼레이터(Operator)라는 용어로 통일해서 사용하겠습니다.

쿠버네티스에서 CR(Custom Resource)을 생성하면, 해당 CR을 담당하는 오퍼레이터가 , 원하는 목적을 이루기 위해서 여러가지 작업을 실행합니다. CR을 생성한 사용자의 실수나, 시스템의 오류 등의 여러 이유 때문에 이러한 작업이 실패할 수 있습니다. 작업이 실패할 경우 사용자에게 오류의 이유를 알려줘야지, 사용자가 무슨 문제인지 인식 할 수 있을 것입니다.

오류를 알려줄 수 있는 가장 쉬운 방법은 로그를 남기는 것입니다. 간단히 생각하면, 오퍼레이터의 로그에 오류 내용을 출력하면 되는것입니다. 하지만, 이 방법은 한 가지 문제를 가지고 있습니다. 바로 권한 문제입니다. CR을 생성한 사용자가, 오퍼레이터의 로그를 볼 권한을 가지고 있다고는 볼 수가 없기 때문입니다. 일반적으로 CR을 생성하는 권한과, 오퍼레이터를 관리하는 권한은 별도로 분리가 되어 있기 때문에, CR 사용자는 오퍼레이터의 로그를 볼 수가 없는 것입니다.

로그를 볼 수 없는 CR 사용자는, 어떤 이유 때문에 문제가 발생했는지를 알 수 없게 되어서, 작업을 진행하는데 심각한 어려움을 겪게 될 것입니다. 물론 오퍼레이터 관리자가 문제를 찾아서, CR 사용자 한테 전달해 줄 수는 있지만, 이 과정은 상당한 병목 현상을 발생시킬 것입니다. CR 사용자에게 어떤 부분에서 오류가 발생했는지를, 다른 사람의 개입없이 알아낼 수 있게, 정보를 제공해 주는 것이 최선의 방법일것입니다.

Kubernetes Events 는 이러한 오류에 대한 정보를 CR 사용자에게 전달하는데 효과적입니다. 포드(pod)의 이벤트 스트림에 오류 정보를 출력하게 되면, 사용자는 kubectl describe 명령어를 통해서 오류를 조회해 볼 수 있습니다.

다음은 kubectl describe pod을 실행해본 결과입니다. 해당 포드의 이벤트가 출력되는 것을 볼 수 있습니다.

$ kubectl describe pod jupyter-elsa
Name:           jupyter-elsa
Namespace:      snow
...
QoS Class:       Burstable
Node-Selectors:  <none>
Tolerations:     node.kubernetes.io/not-ready:NoExecute for 300s
                 node.kubernetes.io/unreachable:NoExecute for 300s
Events:
  Type    Reason     Age   From                      Message
  ----    ------     ----  ----                      -------
  Normal  Scheduled  16s   default-scheduler         Successfully assigned snow/jupyter-elsa to aries
  Normal  Pulled     11s   kubelet, aries            Container image "docker.io/istio/proxy_init:1.2.9" already present on machine
  Normal  Created    11s   kubelet, aries            Created container istio-init
  Normal  Started    11s   kubelet, aries            Started container istio-init
  Normal  Pulling    9s    kubelet, aries            Pulling image "tensorflow/tensorflow:2.1.0-py3-jupyter"
  Normal  Pulled     9s    kubelet, aries            Successfully pulled image "tensorflow/tensorflow:2.1.0-py3-jupyter"
  Normal  Created    9s    kubelet, aries            Created container jupyter
  Normal  Started    8s    kubelet, aries            Started container jupyter
  Normal  Pulled     8s    kubelet, aries            Container image "docker.io/istio/proxyv2:1.2.9" already present on machine
  Normal  Created    8s    kubelet, aries            Created container istio-proxy
  Normal  Started    8s    kubelet, aries            Started container istio-proxy

이벤트 기록하기

이벤트를 기록하기 위해서는, EventRecorder 를 생성해야합니다. EventRecordercontroller-runtimemanager를 이용해서 쉽게 생성할 수 있습니다.

https://github.com/kubernetes-sigs/controller-runtime/blob/v0.4.0/pkg/manager/manager.go#L86

// Manager initializes shared dependencies such as Caches and Clients, and provides them to Runnables.
// A Manager is required to create Controllers.
type Manager interface {
...
	// GetEventRecorderFor returns a new EventRecorder for the provided name
	GetEventRecorderFor(name string) record.EventRecorder
...

다음은 EventRecorder의 코드입니다.

// EventRecorder knows how to record events on behalf of an EventSource.
type EventRecorder interface {
	// Event constructs an event from the given information and puts it in the queue for sending.
	// 'object' is the object this event is about. Event will make a reference-- or you may also
	// pass a reference to the object directly.
	// 'type' of this event, and can be one of Normal, Warning. New types could be added in future
	// 'reason' is the reason this event is generated. 'reason' should be short and unique; it
	// should be in UpperCamelCase format (starting with a capital letter). "reason" will be used
	// to automate handling of events, so imagine people writing switch statements to handle them.
	// You want to make that easy.
	// 'message' is intended to be human readable.
	//
	// The resulting event will be created in the same namespace as the reference object.
	Event(object runtime.Object, eventtype, reason, message string)

	// Eventf is just like Event, but with Sprintf for the message field.
	Eventf(object runtime.Object, eventtype, reason, messageFmt string, args ...interface{})

	// PastEventf is just like Eventf, but with an option to specify the event's 'timestamp' field.
	PastEventf(object runtime.Object, timestamp metav1.Time, eventtype, reason, messageFmt string, args ...interface{})

	// AnnotatedEventf is just like eventf, but with annotations attached
	AnnotatedEventf(object runtime.Object, annotations map[string]string, eventtype, reason, messageFmt string, args ...interface{})
}

그럼 operator-sdk로 생성한 controller에서 Kubernetes Events를 사용하는 방법을 간단히 살펴 보도록 하겠습니다.

커스텀 리소스의 이름이 Jupyter 라고 가정하겠습니다. 먼저 jupyter_controller.go 파일을 열어서, ReconcileJupyterrecorder record.EventRecorder 을 추가합니다.

// ReconcileJupyter reconciles a Jupyter object
type ReconcileJupyter struct {
	// This client, initialized using mgr.Client() above, is a split client
	// that reads objects from the cache and writes to the apiserver
	client client.Client
	scheme *runtime.Scheme
	recorder record.EventRecorder
}

그런 다음 func newReconcilerEventRecorder 를 생성해서 넘겨줍니다.

// newReconciler returns a new reconcile.Reconciler
func newReconciler(mgr manager.Manager) reconcile.Reconciler {
	return &ReconcileJupyter{client: mgr.GetClient(), scheme: mgr.GetScheme(), recorder: mgr.GetEventRecorderFor("jupyter-controller")}
}

그리고, 필요한 곳에서 EventRecorder를 사용해서, 이벤트를 기록합니다.

import corev1 "k8s.io/api/core/v1"

// Pod event reason list
const (
	CreatedPod				= "CreatedPod"
	FailedToCreatePod			= "FailedToCreatePod"
)

r.recorder.Eventf(jupyter, corev1.EventTypeNormal, CreatedPod, "Created pod : %s", pod.Name)
r.recorder.Eventf(jupyter, corev1.EventTypeWarning, FailedToCreatePod, err.Error())

앞서 설명한 바와 같이, 이벤트를 기록한 후, Jupyer 라는 커스텀 리소스에 대해 세부 내용을 출력해보면, 다음과 같은 결과를 얻을 수 있습니다.

$ kubectl describe elsa
Name:         elsa
Namespace:    snow
...
Events:
  Type    Reason      Age    From                Message
  ----    ------      ----   ----                -------
  Normal  CreatedPod  3s     jupyter-controller  Created pod : jupyter-elsa

지금까지 Kubernetes Events를 사용 방법에 대해 알아봤습니다. 오류 내용을 CR 사용자에게 제공하여 문제를 해결하는데 많은 도움이 될것입니다.

참고

EventRecorder를 직접 생성하는 코드입니다.

import (
	"fmt"
	"github.com/go-logr/logr"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/client-go/kubernetes"
	typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/record"
)


func NewEventRecorder(config *rest.Config, scheme *runtime.Scheme, logger logr.Logger, name string) (record.EventRecorder, error) {
	clientSet, err := kubernetes.NewForConfig(config)
	if err != nil {
		return nil, fmt.Errorf("failed to init clientSet: %v", err)
	}

	broadcaster := record.NewBroadcaster()
	broadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{Interface: clientSet.CoreV1().Events("")})
	broadcaster.StartEventWatcher(
		func(e *corev1.Event) {
			logger.V(1).Info(e.Type, "object", e.InvolvedObject, "reason", e.Reason, "message", e.Message)
		})

	recorder := broadcaster.NewRecorder(scheme, corev1.EventSource{Component: name})
	return recorder, nil
}

Sealed Secretes

Secrets in Kubernetes

쿠버네티스를 사용하다 보면, 수 많은 YAML 파일들을 만들어 낼 것입니다. 그리고 이 YAML 파일들을 잘 관리하기 위해서 저장소를 찾게 됩니다. 일반적으로는 평소 즐겨 사용하던 git 을 저장소로 많이 사용합니다.

YAML 파일을 git 저장소에 올리는 것 자체는 별로 어렵지 않습니다. 하지만 파일에 정의된 쿠버네티스 리소스들을 하나씩 하나씩 관심있게 살펴보게 되면, 과연 이 리소스를 올리는게 맞는지 의문을 가지게 만드는 리소스가 있습니다. 바로 Secret 리소스 입니다.

쿠버네티스의 Secret 리소스는 키(key)-값(value)의 쌍으로 이루어져 있으며, 메타 데이터를 가지고 있습니다.

ConfigMap과 비슷하게 생겼지만, Secret에는 민감한 데이터를 저장하기 때문에 보호받아야하는 차이점이 존재합니다.

아래 예제는 Secret 를 YAML로 표현한것입니다.

apiVersion: v1
kind: Secret
metadata:
  name: my-secret
  namespace: default
type: Opaque
data:
  username: YWRtaW4=
  password: aGVsbG8td3JvbGQ=

usernamepassword 라는 키가 있고, Base64 로 인코딩된 값들이 저장되어 있습니다. 문제는 Base64 인코딩이 암호화가 아니라는 점입니다. 단순히 64진수로 변환된것 뿐이라서, 아주 쉽게 디코딩을 할 수 있습니다.

예제에 나와 있는 password의 값을 단순히 디코딩하면, 원래 값을 알 수가 있는 것입니다.

$ echo aGVsbG8td3JvbGQ= | base64 --decode
hello-world

이러한 Secret 을 git에 그냥 올리게 되면, 위험한 상황에 빠질 수도 있게 되는 것입니다.

그러면 어떻게 해야 할까요?

그것은 Secret 을 암호화 해서 git에 저장하는 것입니다. 다행히도 이러한 기능을 하는 도구들이 이미 나와 있습니다. 바로 Sealed SecretsKamus 입니다.

이 문서에는 Sealed Secrets 에 대해 간단히 다룰 것입니다.

Sealed Secrets

Sealed Secrets 는 두 개의 부분으로 이루어져 있습니다. 쿠버네티스 클러스터에 설치할 sealed-secrets-controller 와 암호화 유틸리티인 kubeseal입니다.

Sealed SecretsSealedSecret라는 CR(Custom Resource) 사용해서 Secrets 을 보호합니다. 사용자는 Secrets 리소스를 직접 생성하지 않고, SealedSecret 라는 커스텀 리소스를 생성합니다. 이 SealedSecret 리소스에는 중요한 값들이 암호화 되어 저장됩니다. 이러한 중요한 값들을 암호화 할 때 사용하는 도구가 kubeseal 입니다. SealedSecret 리소스가 클러스터에 등록되면, sealed-secrets-controller 가 해당 리소스의 암호화된 값들을 복호하해서 Secrets 리소스를 생성해 주는 것입니다.

설치하기

Controller 설치

SealedSecret 이라는 CRD를 생성하고, Controller를 설치하면 됩니다.

아래 예제는 CRD를 생성하고, kube-system 네임스페이스에 Controller를 설치하는 방법입니다.

$ kubectl apply -f <https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.9.6/controller.yaml>

controller.yaml

---
apiVersion: v1
kind: Service
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-controller
  name: sealed-secrets-controller
  namespace: kube-system
spec:
  ports:
  - port: 8080
    targetPort: 8080
  selector:
    name: sealed-secrets-controller
  type: ClusterIP
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: RoleBinding
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-service-proxier
  name: sealed-secrets-service-proxier
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: sealed-secrets-service-proxier
subjects:
- apiGroup: rbac.authorization.k8s.io
  kind: Group
  name: system:authenticated
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: Role
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-key-admin
  name: sealed-secrets-key-admin
  namespace: kube-system
rules:
- apiGroups:
  - ""
  resources:
  - secrets
  verbs:
  - create
  - list
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-controller
  name: sealed-secrets-controller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: secrets-unsealer
subjects:
- kind: ServiceAccount
  name: sealed-secrets-controller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  annotations: {}
  labels:
    name: secrets-unsealer
  name: secrets-unsealer
rules:
- apiGroups:
  - bitnami.com
  resources:
  - sealedsecrets
  verbs:
  - get
  - list
  - watch
  - update
- apiGroups:
  - ""
  resources:
  - secrets
  verbs:
  - get
  - create
  - update
  - delete
- apiGroups:
  - ""
  resources:
  - events
  verbs:
  - create
  - patch
---
apiVersion: v1
kind: ServiceAccount
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-controller
  name: sealed-secrets-controller
  namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-controller
  name: sealed-secrets-controller
  namespace: kube-system
spec:
  minReadySeconds: 30
  replicas: 1
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      name: sealed-secrets-controller
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations: {}
      labels:
        name: sealed-secrets-controller
    spec:
      containers:
      - args: []
        command:
        - controller
        env: []
        image: quay.io/bitnami/sealed-secrets-controller:v0.9.6
        imagePullPolicy: Always
        livenessProbe:
          httpGet:
            path: /healthz
            port: http
        name: sealed-secrets-controller
        ports:
        - containerPort: 8080
          name: http
        readinessProbe:
          httpGet:
            path: /healthz
            port: http
        securityContext:
          readOnlyRootFilesystem: true
          runAsNonRoot: true
          runAsUser: 1001
        stdin: false
        tty: false
        volumeMounts:
        - mountPath: /tmp
          name: tmp
      imagePullSecrets: []
      initContainers: []
      serviceAccountName: sealed-secrets-controller
      terminationGracePeriodSeconds: 30
      volumes:
      - emptyDir: {}
        name: tmp
---
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: sealedsecrets.bitnami.com
spec:
  group: bitnami.com
  names:
    kind: SealedSecret
    listKind: SealedSecretList
    plural: sealedsecrets
    singular: sealedsecret
  scope: Namespaced
  version: v1alpha1
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: Role
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-service-proxier
  name: sealed-secrets-service-proxier
  namespace: kube-system
rules:
- apiGroups:
  - ""
  resourceNames:
  - 'http:sealed-secrets-controller:'
  - sealed-secrets-controller
  resources:
  - services/proxy
  verbs:
  - create
  - get
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: RoleBinding
metadata:
  annotations: {}
  labels:
    name: sealed-secrets-controller
  name: sealed-secrets-controller
  namespace: kube-system
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: sealed-secrets-key-admin
subjects:
- kind: ServiceAccount
  name: sealed-secrets-controller
  namespace: kube-system

kubeseal 설치

MacOS

brew install kubeseal

Linux x86-64

wget <https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.9.6/kubeseal-linux-amd64> -O kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal

SealedSecret 생성하기

우선 kubectl--dry-run을 사용해서 my-secret.yaml 파일을 생성합니다.

kubectl -n default create secret generic my-secret \\
  --from-literal=username=admin \\
  --from-literal=password=hello-wrold \\
  --dry-run \\
  -o yaml > my-secret.yaml

생성한 my-secret.yaml 파일의 내용은 다음과 같습니다.

apiVersion: v1
data:
  password: aGVsbG8td3JvbGQ=
  username: YWRtaW4=
kind: Secret
metadata:
  creationTimestamp: null
  name: my-secret
  namespace: default

kubeseal을 실행해서, my-secret.yaml 파일을 my-sealed-secret.yaml 파일로 변환합니다. my-sealed-secret.yaml 파일이 생성될때, 값들은 암호화되어 저장됩니다.

kubeseal --format=yaml < my-secret.yaml > my-sealed-secret.yaml

생성한 my-sealed-secret.yaml 파일의 내용은 다음과 같습니다.

apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
  creationTimestamp: null
  name: my-secret
  namespace: default
spec:
  encryptedData:
    password: AgAkqnrtawbZwPiOuleW5VDHTy8DtnSRqpW8xfV+um6qtqpFVOurbEXAmGWZUhv0MhvppXdHokccRDyS/iaODZGTDXALqrpfS2KMbsX3ARhUXPM5/4G8x9MrjZYGGZNYEJnY78ZvzxlMv7EWsDHTa+9UnWPAorNXd/KDjBhXujohdzs7rPmrAFQWznLovcdqSSXkBMQyf4J19wKPit9H8wBIfHkB4kftI8106NOU6NK+y9s0T9mXIGalS0h5B8KbntvnGmR1/QmxEDJeD7pRSWjAx2m8eRFUsDxRif9uJP2tkwKtQlLJrmsW9YWeDWzeszjAkcniNZtRDkak2mHs3LQZl/vq/otj3V/JuRxVYbukFkyjkvQghbfQek8CTJ7s4kZzozEEwtKWugtSxW2dh7JCewvy18W+IYjxqNDok7sA67bGTycRRBY3+db9mirdE5PxhoHQAkvDu9cUUAgM/+SR2FkpJQ9lGPKWkRiEi3Nx4ZhIJ/4PE+3swZj95BBFLK50gI32YAPnop7SO99Gt9yU7oPK7zoV9r9TNDNvZinB37r7Y26ti3ASrKYH5AY0jAqNcnK2KuzSk44NBRmQ6Hmo0/NZiONBtSckhuzpSw0jmr8KTjp3eZPjk4KLUIW/lEYdAl3kfx3Kd8+QZz6ygALLJWxPPheZm3sd1VHV7F+Bnpo4zLC8pDh9uW0spMCMYQD5adRK+FkXEgnuTw==
    username: AgBbJRLu/+ke7Ze5EgZIRbQHNgwFP/oOz8w2XlkU3oyZczvK6AqkCFUZpHAO3r02LOPpNmiKzRZKOts508LtAFcYNawDeWOS2S29x+m8DQHiYuiKQ0UDBkoSquaEAiUXBBPJomOMS2uDuOLRq2vC4KaQ7N27eQgw1JFR/GvTH0ZPf3hTZ0if0PFMOPcbm4UmCI1Qeoo0ToiOhvgOcpdy6xbcEOyOFX5L+663P5GsZJmZ3F6IoMcSI7QN/iFQXytifW/9GXKSyv4un+dtIZAmjX+sxeUr22nsrBwSJxXy8jKfnO4tNG/XzlPk91ZVu37A5nYJj2+iNVaW0j+1fu80Kcto02rFaC3eFZ+D6q/RTD0cyhJlsNDKFk93il9K7hoAJ7nkyEW4vQ+hpVBsRvRWy2yGYXs/S5tjMtBbgWPYVwlFwR1zoWAd6/t4o+u3xNcJB/LWgBOhuW+Z7TPHGffMxPe3yqgqsLy16iDytjdjsZ83v8t/Fl1OBhWH4ssSHSMu1GA830wDPeCnCQL/JZHWOpqEeTpG+/FPGpzlt+n+oJ8jHdWeErSal3JfMSOLubKkGWVB7pdAYgzl8Pn47Q/GA5QehBqJiaOME/iL93ONhOmJFyI9ErQ9sqCT6YRsvRqxKy0BZgdud2Et1/W8oVZnR6Ev0e5hG/UbJ3SCiocYusWyKwFcmO0xEydxQA85miCvu78DeaJ1aA==
  template:
    metadata:
      creationTimestamp: null
      name: my-secret
      namespace: default
status: {}

SealedSecret 를 클러스터에 등록하기

생성한 SealedSecret 리소스를 클러스터에 등록해 보겠습니다. 네임스페이스는 default입니다.

kubectl일 실행해서 secretssealedsecrets을 조회해 보면 나오지 않습니다.

$ kubectl -n default get secrets
NAME                  TYPE                                  DATA   AGE
default-token-pz9rt   kubernetes.io/service-account-token   3      20d
istio.default         istio.io/key-and-cert                 3      20d

$ kubectl -n default get sealedsecrets
No resources found in default namespace.

생성한 SealedSecret 리소스를 등록합니다.

$ kubectl apply -f my-sealed-secret.yaml
sealedsecret.bitnami.com/my-secret created

다시 secretssealedsecrets을 조회해 보면 등록한 my-secret가 보이는 것을 알 수 있습니다.

$ kubectl -n default get secrets
NAME                  TYPE                                  DATA   AGE
default-token-pz9rt   kubernetes.io/service-account-token   3      20d
istio.default         istio.io/key-and-cert                 3      20d
my-secret             Opaque                                2      4s

$ kubectl -n default get sealedsecrets
NAME        AGE
my-secret   9s

자동으로 생성된 secret의 내용을 확인해 보겠습니다.

$ kubectl -n default get secrets my-secret -o yaml
apiVersion: v1
data:
  password: aGVsbG8td3JvbGQ=
  username: YWRtaW4=
kind: Secret
metadata:
  creationTimestamp: "2020-01-25T06:37:42Z"
  name: my-secret
  namespace: default
  ownerReferences:
  - apiVersion: bitnami.com/v1alpha1
    controller: true
    kind: SealedSecret
    name: my-secret
    uid: 8ed4ea13-4f89-46e2-9149-b80fe01b46d2
  resourceVersion: "6472553"
  selfLink: /api/v1/namespaces/default/secrets/my-secret
  uid: 466f7e72-9dc0-401b-9e2f-b073d08b5540
type: Opaque

정상적으로 복호화 되어 있는 것을 확인 할 수 있습니다.

기타

인증서를 별도로 사용하기

kubeseal 은 비대칭키 암호화 알고리즘을 사용해서 값들을 암호화 합니다. 별 다른 옵션을 주지 않으면 기본 설정 파일(~/.kube/config)을 이용해서 인증서를 가져온 후, 암호화 작업을 합니다. 상황이 여의치 않는 경우에는 이 인증서 정보를 파일로 저장한 후 직접 사용하실 수 있습니다.

kubeseal --fetch-cert > pub-cert.pem

혹시라도 컨트롤러를 설치한 네임스페이스가 다르거나, 이름이 다른 경우 다음과 같은 옵션을 사용할 수도 있습니다.

kubeseal --fetch-cert \\
  --controller-namespace=kube-system \\
  --controller-name=sealed-secrets-controller \\
> pub-cert.pem

그리고 SealedSecret 리소스를 생성할때 --cert=pub-cert.pem 플래그로 인증서를 지정해 줄 수 있습니다.

kubeseal --format=yaml --cert=pub-cert.pem  < my-secret.yaml > my-sealed-secret.yaml

비대칭키 암호화 알고리즘을 사용하기 때문에, 복호화를 위해서 개인키를 사용합니다. 이 개인키를 잃어버리면, 복호화를 할 수가 없습니다. 그래서 이 개인키를 안전하게 잘 보관해야 합니다.

백업하기

kubectl get secret -n kube-system -l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml >master.key

복구하기

$ kubectl apply -f master.key
$ kubectl delete pod -n kube-system -l name=sealed-secrets-controller

GitOps and Kubernetes

GitOps 스타일의 지속적 배포

쿠버네티스에 애플리케이션을 배포하는 방법은 여러가지가 있습니다.

가장 간단한 방법으로는, 사람이 직접 kubectl 를 실행해서 매니페스트를 클러스터에 적용하는 것입니다. 물론 사람이 실행하기 때문에 번거롭게, 실수가 쉽게 발생한다는 문제가 있습니다. 그래서 보통은 자동화 도구를 사용합니다. SpinnakerJenkins XTektonArgo WorkflowArgo CD 와 같은 CI/CD 도구들을 사용해서 배포 작업을 하는 것입니다.

이 문서에서는 요즘 사용되고 있는 GitOps 스타일의 방법을 사용하여, CI/CD 파이프라인을 만드는 방법에 대해서 간단히 설명하겠습니다.

블루/그린 배포, 카나리아 분석, 멀티 클라우드 배포 등의 고급 기능을 사용하려면, Spinnaker가 더 좋은 선택지가 될 수 있지만, 간단히 사용하기에 Argo CD 로도 충분하다고 생각합니다. 이 문서에는 Argo CD 를 사용합니다.

GitOps

GitOps 라는 용어는 Weaveworks에서 만들었습니다.

GitOps의 핵심은 Git 저장소에 저장된 쿠버네티스 매니페스트 같은 파일을 이용하여, 배포를 선언적으로 한다는 것입니다. 즉, Git에 저장된 매니페스트가 쿠버네티스 클러스터에도 똑같이 반영된다는 것입니다.

이러한 방법은 이해하기 쉬운 운영 모델을 제공하며, Git을 사용하기 때문에 보안 및 감사 기능도 기본으로 제공됩니다. 그리고 재해로부터 쉽게 복구할 수 있습니다. 무엇보다도 큰 장점은 개발자 친화적이라는 것입니다.

이런 선언적 스타일은 쿠버네티스와 잘 어울립니다.

이미 아시고 계신분들도 있지만, 쿠버네티스의 주요한 개념 중 하나는 선언적 시스템이라는 것입니다. 어떠한 리소스를 생성하라 명령하는 것이 아니라, 사용자는 매니페스트를 정의하고, 시스템은 그 상태를 유지하기 위해 노력한다는 것입니다. 이런 점이 상당히 유사하기 때문에 잘 어울린다고 볼 수 있습니다.

앞서 설명한 바와 같이 GitOps란 Git 저장소에 있는 내용을, 쿠버네티스 클러스터에 그대로 반영해주는것입니다. 이것을 그림으로 표현하자면 아래와 같습니다.

Git 저장소에 있는것을 쿠버네티스 클러스터에 동기화 합니다.

CI / CD 파이프라인

일반적으로 많이 사용하는 CI/CD 파이프라인을 대략적으로 그린다면, 다음과 같을 것입니다.

개발자가 소스 코드를 작성하고, Git 저장소에 올립니다. 그러면 Jenkins, CircleCI 같은 CI 툴에 의해서 테스트와 빌드 같은 작업이 실행된 후, 생성한 컨테이너 이미지를 컨테이너 저장소에 업로드 합니다.

그런 다음 CI/CD 툴에서 업로드된 컨테이너 이미지의 정보를 참조하여, 대상 서버에 배포를 하는 것입니다.

GitOps는 이러한 파이프라인의 배포 부분에서 약간 다르게 작동합니다.

컨테이너 이미지를 컨테이너 저장소에 업로드 한 후, 매니페스트가 저장되어 있는 Git 저장소를 가져옵니다. 그리고 매니페스트의 특정 부분(예를 들면 이미지 태그)을 업데이트 한 후, Git 저장소에 올리고 작업을 종료하게 됩니다.

매니페스트가 정의되어 있는 Git 저장소가 변경되면, Git 저장소의 내용과 쿠버네티스 클러스터를 동기화 해주는 에이전트가 변경 내역을 쿠버네티스 클러스터에 반영해 주게 되는 것입니다.

이 문서에는 편의를 위해서, Git 저장소의 내용과 쿠버네티스 클러스터를 동기화 해주는 역할을 하는 에이전트를 GitOps 오퍼레이터(Operator)라고 부르도록 하겠습니다.

이러한 과정을 간단히 코드로 표현하면 다음과 같습니다.

새로운 컨테이너 이미지를 빌드하고 푸시하기

docker build -t example/hello:v2.0 .
docker push example/hello:v2.0

매니페스트를 수정하고 git 저장소에 푸시하기

git clone https://github.com/example/hello-config.git
cd hello-config

kubectl patch --local -f config-deployment.yaml -p '{"spec":{"template":{"spec":{"containers":[{"name":"hello","image":"example/hello:v2.0"}]}}}}' -o yaml

git add . -m "Update hello to v2.0"
git push

이렇게 매니페스트가 저장되어 있는 git 저장소가 업데이트가 되면, GitOps Operator가 해당 내용을 쿠버네티스 클러스터에 반영 즉, 동기화 해주는 것입니다.

GitOps Operator

GitOps 오퍼레이터(Operator)에 대해서 좀 더 알아 보도록 하겠습니다. 앞서 살펴본 봐와 같이 GitOps 오퍼레이터는 Git 저장소 있는 매니페스트를 쿠버네티스 클러스터에 반영해 주는 역할을 합니다.

다음과 같이, 쿠버네티스의 CronJob 을 이용해서 간단히 구현해 볼 수도 있습니다.

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: gitops-cron-job
  namespace: gitops
 spec:
   schedule: "*/10 * * * *"
   backoffLimit: 0
   jobTemplate:
     spec:
       template:
         spec:
           containers:
           - name: gitops-operator
             image: gitops/operator:latest
             command: [sh, -e, -c]
             args:
             - git clone http://github.com/gitops/hello.git /tmp/hello
               find /tmp/hello -name '*.yaml' -exec kubectl apply -f {} \;

10초 마다 주기적으로, Git 저장소의 내용을 가져와서 쿠버네티스 클러스터에 적용 하고 있는 것입니다.

이런 식으로 직접 구현해서 사용할 수도 있지만, 보안이나 모니터링 등 여러 측면에서 불편하기 때문에 기존에 만들어진 GitOps 오퍼레이터를 사용할 것입니다.

널리 알려진 GitOps 오퍼레이터는 Weavework에서 만든 Flux와 Intuit에서 만든 ArgoCD가 있습니다. 이 문서에서는 Argo CD를 사용합니다.

Argo CD

Argo CD는 GitOps스타일의 배포를 지원하는 CD 도구입니다. 원하는 설정 사항을 변경하여 Git에 푸시하면, 자동으로 쿠버네티스 클러스터의 상태가 Git에 정의된 상태로 동기화 됩니다.

즉, 지정한 대상 환경에 애플리케이션을 원하는 상태로 자동으로 배포하는 것입니다.

그뿐만 아니라, 멀티 클러스터 관리/배포 기능도 가지고 있습니다. 그리고 SSO 연동과 멀티 테넌시를 지원하고, RBAC을 사용할 수도 있는 등 여러가지 장점을 가지고 있습니다.

출처 : https://argoproj.github.io/argo-cd/

GitOps 구성하기

Git 저장소

Argo CD를 이용해서, GitOps 스타일의 CI/CD 파이프라인을 구성하는 방법에 대해서 알아보겠습니다.

이 예제에서는 두 개의 Git 저장소를 사용합니다.

  • app 저장소 : 애플리케이션 소스 코드를 저장하고 있습니다.
  • config 저장소 : 쿠버네티스 배포 용 매니페스트를 저장하고 있습니다.

물론 애플리케이션 소스 코드와 매니페스트를 단일 저장소에 저장할 수도 있습니다. 하지만 서로 다른 곳에 저장하는 것이 더 좋기 때문에 분리하는 것을 추천합니다.

app 저장소와 config 저장소를 분리하는 가장 큰 이유는, 용도와 생명 주기가 다르기 때문입니다. app 저장소는 실제 개발자가 주로 사용하며, 애플리케이션 소스코드를 저장하고 있고, config 저장소는 주로 CI/CD 툴 같은 자동화 시스템에서 주로 사용하기 때문입니다.

참고할 예제 소스 코드 저장소는 https://github.com/kangwoo/hello-go 이고, 매니페스트 저장소는 https://github.com/kangwoo/hello-go-deploy 입니다.

GitOps를 구성하기에 앞서, 먼저 결정해야 할 사항이 하나 있습니다. 그것은 바로 매니페스트를 어떻게 만들지 입니다. 기존에 사용하던 쿠버네티스트의 매니페스트를 그대로 사용해도 됩니다. 예를 들면 deployment.yamlservice.yamlingress.yaml 등등 기존에 사용하던 형태 그대로 만들어도 됩니다. 하지만, 배포 환경이 여러개가 된다는 등의 환경 별로 파일을 각자 만들어줘야하는 경우가 생길 수 있습니다. 물론 환경별로 파일을 따로 따로 만들 수도 있지만, 상당히 번거롭습니다. 중복되는 내용이 더 많을 것이기 때문입니다. 그래서 템플릿 같은 것을 사용하면 좀 더 편하게 만들 수 있습니다. 바로 Kustomize나 Helm 등의 툴을 이용하는것입니다. 다행히도 Argo CD 에서는 Kustomize나 HelmKsonnet 등을 지원하기 때문에, 별다른 노력 없이 해당 툴들을 사용할 수 있습니다. 예제에서는 Kustomize 를 사용하도록 하겠습니다.

우선 app 저장소에 소드 코드를 올립니다.

CI 툴을 이용해서, 해당 저장소에서 소스 코드를 클론한다음, 컨테이너 이미지를 빌드하고 푸시하는 파이프라인을 만듭니다. CI 툴은 Jenkins나 CircleCI, Tekton등 아무거나 사용해도 무방합니다. 여기서 중요하게 다를 부분은 GitOps 부분이기 때문에, 컨테이너 이미지를 빌드해서 푸시하는 것에 대해서는 자세히 다루지 않겠습니다.

go 로 작성된 Hello를 출력하는 main.go가 있고, 컨테이너 이미지 빌드를 위한 Dockerfile 이 있습니다. 그리고 젠킨스에서 CI 파이프라인을 정의한 Jenkinsfile 이 있습니다.

main.go

package main

import (
  "fmt"
  "net/http"
)

func main() {
  http.HandleFunc("/", hello)
  http.ListenAndServe(":8080", nil)
}

func hello(w http.ResponseWriter, r *http.Request) {
  fmt.Fprintf(w, "Hello, %s", r.URL.Path[1:])
}

Dockerfile

FROM golang:alpine as builder
RUN mkdir /build
ADD . /build/
WORKDIR /build
RUN go build -o main .

FROM alpine
ENV USER_UID=1001 \
    APP_DIR=/app 
RUN mkdir -p ${APP_DIR} && chown ${USER_UID}:0 ${APP_DIR} && chmod ug+rwx ${APP_DIR}

USER ${USER_UID}
COPY --from=builder /build/main ${APP_DIR}/
WORKDIR ${APP_DIR}
EXPOSE 8080
CMD ["./main"]

Jenkinsfile

def podLabel = "worker-${UUID.randomUUID().toString()}"

podTemplate(label: podLabel, containers: [
  containerTemplate(name: 'docker', image: 'docker', command: 'cat', ttyEnabled: true),
  containerTemplate(name: 'tools', image: 'argoproj/argo-cd-ci-builder:v1.0.1', command: 'cat', ttyEnabled: true),
],
volumes: [
  hostPathVolume(mountPath: '/var/run/docker.sock', hostPath: '/var/run/docker.sock')
]) {
  node(label) {
    def myRepo
    stage('Checkout') {
      myRepo = checkout scm
    }

    def gitCommit = myRepo.GIT_COMMIT
    def shortGitCommit = "${gitCommit[0..7]}"
    def imageTag = shortGitCommit

    stage('Image Build') {
      container('docker') {
        sh "docker build . -t kangwoo/hello-go:${imageTag}"
      }
    }

    stage('Image Push') {
      container('docker') {
        sh "docker push kangwoo/hello-go:${imageTag}"
      }
    }

    stage('Deploy to dev') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'my-git', usernameVariable: 'GIT_USER', passwordVariable: 'GIT_PWD')]) {
          container('tools') {
            sh "git clone https://${GIT_USER}:${GIT_PWD}@github.com/kangwoo/hello-go-deploy.git"
            sh "git config --global user.email '${GIT_USER}@mycompany.com'"

            dir("hello-go-deploy") {
              sh "cd ./overlays/dev && kustomize edit set image kangwoo/hello-go:${imageTag}"
              sh "git commit -am 'Publish new version ${imageTag} to dev' && git push || echo 'no changes'"
            }
          }
        }
      }
    }

  }
}

Jenkinsfile에서 중요하게 봐야할 부분은 stage('Deploy to dev') 입니다.

특정 이미지 태그를 만들어서, 컨테이너 이미지를 빌드하고 푸시한 다음, 해당 스테이지가 실행됩니다. 매니페스트가 정의되어 있는 Git 저장소를 클론하고, kustomize edit set image 명령어를 실행해서, 사용할 이미지 정보를 업데이트 해줍니다. 그런 다음 git 명령어를 이용해서 Git config 저장소에 푸시합니다.

변경된 매니페스트가 Git 저장소에 푸시되면, Argo CD가 변경된 점을 파악해서, 쿠버네티스 클러스터와 동기화 해줍니다.

참고로 git과 kustomize 명령어를 쉽게 사용하기 위해서, argoproj/argo-cd-ci-builder:v1.0.1 이미지를 사용하였습니다.

그 다음, 매니페스트를 만를고, config 저장소에 올립니다.

매니페스트는 kustomize를 사용해서 만들었습니다. 간단히 구조를 살펴보면, base와 overlays 디렉토리를 가지고 있습니다.

.
├── base
│   ├── deployment.yaml
│   ├── kustomization.yaml
│   └── service.yaml
└── overlays
    ├── dev
    │   ├── deployment-patch.yaml
    │   └── kustomization.yaml
    └── prod
        ├── deployment-patch.yaml
        └── kustomization.yaml

base 디렉토리에는 리소스를 정의한 파일이 있습니다. 바로 deployment.yaml과 service.yaml 파일 입니다. 이 파일들에는 쿠버네티스의 Deployment와 Service를 생성하기 위한 명세가 담겨 있습니다. 그리고, kustomization.yaml 라는 파일도 존재하는데, 이 파일은 kustomize 에서 사용하는 파일로서, 기본적인 메타 정보와 어떠한 리소스들을 사용할지에 대한 정보가 담겨 있습니다.

overlays 디렉토리는 다시 dev와 prod 디렉토리로 나누어 집니다. 개발과 프로덕션 환경으로 사용하기 위해서 두 개로 나눈것입니다. dev와 prod 디렉토리에는 각각 메타 정보를 담긴 kustomization.yaml 파일과, 환경별로 패치할 내용이 담긴 deployment-patch.yaml 파일이 존재합니다. 예를 들면, 개발 환경에 반영될때에는, base + overlays/dev 가 합쳐진 결과가 반영이 되는 것입니다.

Argo CD 는 kustomize 를 지원하기 때문에, overlays/dev 같이 해당 디렉토리를 지정해주면, 합쳐진 결과가 자동으로 쿠버네티스 클러스터에 동기화 됩니다.

Argo CD

Git 저장소에 있는 내용을 쿠버네티스 클러스터에 자동으로 동기화 하기 위해서 Argo CD 에 설정을 추가하겠습니다.

Argo CD 웹 화면에 접속한 후, 로그인을 한다음, New App을 클릭합니다.

그리고 아래 값들을 입력한 후, CREATE 버튼을 클릭하면 애플리케이션이 생성됩니다.

웹 화면을 사용하지 않고, 직접 CR을 생성해서 사용할 수도 있습니다.

아래처럼 Application 리소스를 정의한 후, Argo CD 가 설치된 네임스페이스에 해당 리소스를 생성해 주면 됩니다.

cat <<EOF | kubectl -n argocd apply -f -
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: hello-go
spec:
  destination:
    namespace: default
    server: https://kubernetes.default.svc
  project: default
  source:
    path: overlays/dev
    repoURL: https://github.com/kangwoo/hello-go-deploy.git
    targetRevision: HEAD
  syncPolicy:
    automated: {}
EOF

애플리케이션이 정상적으로 생성되면, 화면에서 확인할 수 있습니다. 애플리케이션 이름을 클릭하면 다음과 같은 상세 내용을 볼 수 있습니다.

Kubernetes 커맨드 라인 툴

소개글

쿠버네티스 클러스터를 자주 사용하는 사람이라면, 반복적인 명령을 입력하는 행위에 많은 불편함을 느낄것입니다. 이러한 불편함을 줄이기 위해서 kubectl관련 CLI 도구들을 사용할 수 있습니다. 이 문서에서는 kubectl의 사용을 도와줄 몇가지 도구들을 소개하겠습니다.

kubectl-aliases : 별칭 주기

매번 kubectl이나 다른 명령어를 입력할 수 있지만, 상당히 불편함을 느낄것입니다. 이런 불편함을 줄이는 가장 쉬운 방법은 별칭(alias)를 지정해서 사용하는 것입니다.

alias k='kubectl'
alias kg='kubectl get'
alias kgpo='kubectl get pod'
...

위의 예제처럼 별칭을 지정해 놓으면, 보다 효율적으로 명령어를 입력할 수 있습니다. 자주 쓰는 명령어는 kubectl-aliases에 정의되어 있습니다. 참고하시기 바랍니다.

Shell Autocompletion : 쉡 자동완성 하기

kubectl의 명령어를 일일이 입력하고 있다면, 쉘 자동 완성을 설정하는게 좋습니다. 쉘에 kubectl 입력하고 [Tab]키를 눌러서 자동완성을 시도하면, 추천 단어 목록을 보여주거나, 추천 단어가 1개일 경우 자동으로 완성이 됩니다. kubectl이 대한 쉘 자동 완성을 설정하는 방법은 설치 문서를 참고하시기 바랍니다.

자동 완성 사용하기

예를 들어서 g를 입력하고 tab을 누르면, get으로 자동 완성됩니다.

$ kubectl g [Tab]
$ kubectl get

get p를 입력하고 tab을 누르면, p로 시작하는 단어 목록을 보여줍니다.

$ kubectl get p [Tab]
persistentvolumeclaims             podsecuritypolicies.policy
persistentvolumes                  podtemplates
poddisruptionbudgets.policy        policies.authentication.istio.io
pods                               priorityclasses.scheduling.k8s.io
podsecuritypolicies.extensions 

kubectx & kubecns : 컨텍스트와 네임스페이스 쉽게 변경하기

쿠버네티스의 컨텍스트를 여러개 사용하고 있거나, 네임스페이스를 여러개 사용하고 있다면, 이 도구들이 도움이 될것입니다. kubectx는 컨텍스트를 쉽게 변경할 수 있도록 도움을 줍니다. 이 도구를 사용하면, kubectl config use-context greentea 같은 긴 명령어를 사용하지 않아도 됩니다. 그리고 kubens는 기본 네임스페이스를 변경할 수 있도록 도와줍니다. 이 두 도구 모드 [Tab] 완성을 지원합니다. 그 뿐만 아니라, fzf를 설치하면 대화식 메뉴를 제공하기 때문에, 더욱 편리하게 사용할 수 있습니다.

kubectx 와 kubens에 대한 설정 방법은 설치 문서를 참고하시기 바랍니다.

kubectx 사용하기

kubectx 명령을 실행하면, 컨텍스트 목록을 보여줍니다.

$ kubectx
coffee
greentea

컨텍스트를 변경하기 위해서는, 컨텍스트 명을 입력하면 됩니다.

$ kubectx greentea
Switched to context "greentea".

만약 fzf가 설치되어 있으면, kubectx 명령을 실행하면 대화식 메뉴를 보여줍니다.

$ kubectx
> coffee
  greentea
  2/2

kubens 사용하기

kubens 명령을 실행하면, 네임스페이스 목록을 보여줍니다.

$ kubens
kube-system
kube-public
istio-system
default

네임스페이스를 변경하기 위해서는, 네임스페이스 명을 입력하면 됩니다.

$ kubens kube-system
Context "greentea" modified.
Active namespace is "kube-system".

만약 fzf가 설치되어 있으면, kubens 명령을 실행하면 대화식 메뉴를 보여줍니다.

$ kubectx
> kube-system
  kube-public
  istio-system
  default
  4/4

kube-ps1 : 컨템스트와 네임스페이스 이름을 쉘 프로프트에 보여주기

쿠버네티스의 컨텍스트나 네임스페이스를 여러개 사용하고 있을때, 현재 어떤 컨텍스트와 네임스페이스를 사용하고 있는지 헷갈리는 경우가 많습니다. kube-ps1은 현재 사용하고 있는 쿠버네티스 컨텍스트 및 네임스페이스를 쉘의 프롬프 문자열에 보여줍니다. 이 도구를 사용하면, kubectl config current-context 같은 긴 명령어를 사용하지 않아도 됩니다. 그리고, 컨텍스트와 네임스페이스를 보는게 불편하다면, kubeoff 명령어를 실행해서 kube-ps을 비활성화 시킬수도 있습니다. 물론, 다시 컨텍스트와 네임스페이스를 보고 싶다면 kubeon 명령어를 실행하면 됩니다. kube-ps1`에 대한 설정 방법은 설치 문서를 참고하시기 바랍니다.

kube-ps1의 설치가 완료되면, 셀의 프롬프트가 다음처럼 표시됩니다.

(⎈ |[context]:[namespace]) $
(⎈ |greentea:kube-system) $

stern : 여러개의 팟(pod)이나 컨테이너의 로그를 쉽게 보기

kubectl은 기본적으로 팟이나 컨테이너의 로그를 테일링해서 볼 수 있는 기능을 제공하고 있습니다. 하지만, 팟이 여러개이거나 하나의 팟에 여러개의 컨테이너가 있을 경우는 로그른 테일링 해서 볼 수가 없습니다.

stern는 쿠버네티스트의 여러 팟이나, 팟 내의 여러 컨테이너의 로그를 테일링 할 수 있도록 해줍니다. 그리고 대상별로 출력되는 로그가 색상별로 표현되기 때문에 쉽게 구별할 수 있습니다. stern`에 대한 설정 방법은 설치 문서를 참고하시기 바랍니다.

stern ginger이라는 명령어를 실행하면, ginger이라는 이름을 가진 팟에 속한 컨테니이너들의 로그를 모두 보여줍니다. 이 쿼리는 정규식이기 때문에 팟 이름을 쉽게 필터링할 수 있으며, 정확한 이름을 지정하지 않아도 됩니다. 다시 말해성 ginger로 시작하는 이름을 가진 모든 팟들의 로그를 보여주게 되는것입니다.

$ stern ginger
hello gingersnap istio-proxy 2019-12-02T09:05:41.739784Z	info	watchFileEvents: "/etc/certs": MODIFY|ATTRIB
hello gingersnap istio-proxy 2019-12-02T09:05:41.739842Z	info	watchFileEvents: "/etc/certs/..2019_11_06_05_09_24.409981738": MODIFY|ATTRIB
...

그리고 아래처럼, 레이블을 가지고 쿼리할 수 있습니다.

stern --all-namespaces -l run=cookie

k9s : 터미널 UI

k9s는 쿠버네티스 클러스터와 상호 작용할 수 있는 터미널 UI를 제공합니다. 이 도구는 쿠버네티스 리소스들을 쉽게 탐색하고 관리할 수 있도록 도와줍니다. ‘k9s’에 대한 설정 방업은 설치 문서를 참고하시기 바랍니다

Pod 조회하기

로그 보기

Ingress status에 값이 없을 경우

Ingress status에 값이 없다.

argocd를 이용해서, 리소스들을 동기화 했는데, ingress 부분 계속 Processing이라고 나오이고 끝날 생각을 안한다. ingress 의 상태를 보니 다음과 같았다.

$ kubectl get ingresses dobby -o yaml
...
status:
  loadBalancer: {}

뭔가 저기 status에 값이 할당되어야 할거 같은 느낌이 들었지만, 왜 안되어 있는지 이유는 알지 못했다. 그래서 사용중인 nginx-ingress-controller의 설정을 살펴보았다.

...
      containers:
        - name: nginx-ingress-controller
          image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.25.0
          args:
            - /nginx-ingress-controller
            - --default-backend-service=$(POD_NAMESPACE)/default-http-backend
            - --configmap=$(POD_NAMESPACE)/nginx-configuration
            - --tcp-services-configmap=$(POD_NAMESPACE)/tcp-services
            - --udp-services-configmap=$(POD_NAMESPACE)/udp-services
            - --publish-service=$(POD_NAMESPACE)/ingress-nginx
            - --annotations-prefix=nginx.ingress.kubernetes.io
...

--publish-service 플래그를 사용해서, ingress-nginx 서비스의 IP를 읽어와서 업데이트 해주는거 같은데, 불행히도 해당 ingress-nginx 서비스가 LoadBalancer이 아니라서 IP가 존재하지 않는다. 그래서 --publish-service 플래그를 삭제하고, --publish-service 플래그를 사용해서 직접 명시해주었다.

...
      containers:
        - name: nginx-ingress-controller
          image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.25.0
          args:
            - /nginx-ingress-controller
            - --default-backend-service=$(POD_NAMESPACE)/default-http-backend
            - --configmap=$(POD_NAMESPACE)/nginx-configuration
            - --tcp-services-configmap=$(POD_NAMESPACE)/tcp-services
            - --udp-services-configmap=$(POD_NAMESPACE)/udp-services
            - --publish-status-address=10.xx.yy.zz
            - --annotations-prefix=nginx.ingress.kubernetes.io
...

변경사항을 반영하고, ingress 의 상태를 조회를 해보니, 정상적으로 상태값이 반영되었다.

$ kubectl get ingresses dobby -o yaml
...
status:
  loadBalancer:
    ingress:
    - ip: 10.xx.yy.zz

ingress 의 상태값이 반영된 후, argocd도 정상적으로 작동하였다.

참고

  • https://github.com/kubernetes/ingress-nginx/blob/master/docs/examples/static-ip/README.md

Prometheus Operator로 kube-proxy 모니터링 하기

kube-proxy 모니터링 하기

kube-proxy도 /metrics라는 매트릭 엔드 포인트를 제공한다. 하지만 기본 설정값이 127.0.0.1:10249이기 때문에, 외부에서 접근이 안된다.

그래서 prometheus에서 수집하려고 하면, 접근이 안되서 문제가 발생한다.

설정 변경하기

$ kubectl -n kube-system edit cm/kube-proxy 
## Change from
    metricsBindAddress: 127.0.0.1:10249
## Change to
    metricsBindAddress: 0.0.0.0:10249

물론 0.0.0.0:10249로 변경하고, 모든 곳에서 다 접근이 가능하기 때문에, 보안이 취약한 곳이라면 사용하지 않는것이 좋다.

설정을 변경하고, kube-proxy를 재시작하면 적용이 된다.

kubectl -n kube-system delete pod -l k8s-app=kube-proxy 

Prometheus Operator로 etcd 모니터링 하기

etcd 모니터링 하기

etcd는 /metrics 라는, 프로메테우스가 수집할 수 있는 매트릭 엔드 포인트를 제공한다. 하지만, Secure Etcd 클러스터인 경우에는 해당 엔드 포인트에 접근하기 위해서는 인증서가 필요하다.

(다른 방법으로는 /metrics 엔드 포인트를 다른 포트로 분리하여, 인증서 없이 접근할 수도 있다. --listen-metrics-urls 옵션을 참고 바란다.)

환경

helm을 사용해서 prometheus-operator를 설치할 것이다. 그래서 prometheus-operator를 설치할때, etcd를 모니터링하도록 설정 파일을 변경해서 사용한다.

values.yaml 수정하기

kubeEtcd

kubeEtcd.serviceMonitor의 값들을 변경한다. scheme를 https로 변경하고, 인증서 정보를 등록한다.

## Component scraping etcd
##
kubeEtcd:
...
  serviceMonitor:
    scheme: https
    insecureSkipVerify: false
    serverName: localhost
    caFile: /etc/prometheus/secrets/etcd-client-cert/etcd-ca
    certFile: /etc/prometheus/secrets/etcd-client-cert/etcd-client
    keyFile: /etc/prometheus/secrets/etcd-client-cert/etcd-client-key
...

prometheus

프로메테우스를 기동할때 etcd-client-cert란 이름의 secret를 pod에 마운트하기 위해서, prometheus.secrets에 etcd-client-cert를 추가해 준다. 그리고, etcd 스크랩 설정 추가를 위해서, prometheus.additionalScrapeConfigs의 kube-etcd 부분을 활성화 해준다

## Deploy a Prometheus instance
##
prometheus:
...
    secrets:
      - "etcd-client-cert"

...

    additionalScrapeConfigs:
      - job_name: kube-etcd
        kubernetes_sd_configs:
          - role: node
        scheme: https
        tls_config:
          ca_file:   /etc/prometheus/secrets/etcd-client-cert/etcd-ca
          cert_file: /etc/prometheus/secrets/etcd-client-cert/etcd-client
          key_file:  /etc/prometheus/secrets/etcd-client-cert/etcd-client-key
        relabel_configs:
        - action: labelmap
          regex: __meta_kubernetes_node_label_(.+)
        - source_labels: [__address__]
          action: replace
          target_label: __address__
          regex: ([^:;]+):(\d+)
          replacement: ${1}:2379
        - source_labels: [__meta_kubernetes_node_name]
          action: keep
          regex: .*mst.*
        - source_labels: [__meta_kubernetes_node_name]
          action: replace
          target_label: node
          regex: (.*)
          replacement: ${1}
        metric_relabel_configs:
        - regex: (kubernetes_io_hostname|failure_domain_beta_kubernetes_io_region|beta_kubernetes_io_os|beta_kubernetes_io_arch|beta_kubernetes_io_instance_type|failure_domain_beta_kubernetes_io_zone)
          action: labeldrop
          
...

인증서 복사하기

etcd 인증서를, 프로메테스를 설치할 monitoring 네임스페이스에, etcd-client-cert란 이름의 secret로 복사한다.

POD_NAME=$(kubectl get pods -o=jsonpath='{.items[0].metadata.name}' -l component=kube-apiserver -n kube-system)

kubectl create secret generic etcd-client-cert -n monitoring \
  --from-literal=etcd-ca="$(kubectl exec $POD_NAME -n kube-system -- cat /etc/kubernetes/pki/etcd/ca.crt)" \
  --from-literal=etcd-client="$(kubectl exec $POD_NAME -n kube-system -- cat /etc/kubernetes/pki/etcd/healthcheck-client.crt)" \
  --from-literal=etcd-client-key="$(kubectl exec $POD_NAME -n kube-system -- cat /etc/kubernetes/pki/etcd/healthcheck-client.key)"

helm으로 prometheus-operator 설치하기

helm 설치 명령어로, proemtheus-operator를 설치한다. 수정한 설정값을 적용하기 위해서 --values values.yaml 옵션을 사용한다.

helm install stable/prometheus-operator --name mon --namespace monitoring --values values.yaml --tls

참고 사항

helm으로 prometheus-operator를 삭제할때, crd는 자동으록 삭제되지 않는다. 아래 명령어로 직접 삭제해야한다.

kubectl delete --ignore-not-found customresourcedefinitions \
  prometheuses.monitoring.coreos.com \
  servicemonitors.monitoring.coreos.com \
  podmonitors.monitoring.coreos.com \
  alertmanagers.monitoring.coreos.com \
  prometheusrules.monitoring.coreos.com

Prometheus Operator로 etcd 모니터링 하기

 1 분 소요

etcd 모니터링 하기

etcd는 /metrics 라는, 프로메테우스가 수집할 수 있는 매트릭 엔드 포인트를 제공한다. 하지만, Secure Etcd 클러스터인 경우에는 해당 엔드 포인트에 접근하기 위해서는 인증서가 필요하다.

(다른 방법으로는 /metrics 엔드 포인트를 다른 포트로 분리하여, 인증서 없이 접근할 수도 있다. --listen-metrics-urls 옵션을 참고 바란다.)

환경

helm을 사용해서 prometheus-operator를 설치할 것이다. 그래서 prometheus-operator를 설치할때, etcd를 모니터링하도록 설정 파일을 변경해서 사용한다.

values.yaml 수정하기

kubeEtcd

kubeEtcd.serviceMonitor의 값들을 변경한다. scheme를 https로 변경하고, 인증서 정보를 등록한다.

## Component scraping etcd
##
kubeEtcd:
...
  serviceMonitor:
    scheme: https
    insecureSkipVerify: false
    serverName: localhost
    caFile: /etc/prometheus/secrets/etcd-client-cert/etcd-ca
    certFile: /etc/prometheus/secrets/etcd-client-cert/etcd-client
    keyFile: /etc/prometheus/secrets/etcd-client-cert/etcd-client-key
...

prometheus

프로메테우스를 기동할때 etcd-client-cert란 이름의 secret를 pod에 마운트하기 위해서, prometheus.secrets에 etcd-client-cert를 추가해 준다. 그리고, etcd 스크랩 설정 추가를 위해서, prometheus.additionalScrapeConfigs의 kube-etcd 부분을 활성화 해준다.

## Deploy a Prometheus instance
##
prometheus:
...
    secrets:
      - "etcd-client-cert"

...

    additionalScrapeConfigs:
      - job_name: kube-etcd
        kubernetes_sd_configs:
          - role: node
        scheme: https
        tls_config:
          ca_file:   /etc/prometheus/secrets/etcd-client-cert/etcd-ca
          cert_file: /etc/prometheus/secrets/etcd-client-cert/etcd-client
          key_file:  /etc/prometheus/secrets/etcd-client-cert/etcd-client-key
        relabel_configs:
        - action: labelmap
          regex: __meta_kubernetes_node_label_(.+)
        - source_labels: [__address__]
          action: replace
          target_label: __address__
          regex: ([^:;]+):(\d+)
          replacement: ${1}:2379
        - source_labels: [__meta_kubernetes_node_name]
          action: keep
          regex: .*mst.*
        - source_labels: [__meta_kubernetes_node_name]
          action: replace
          target_label: node
          regex: (.*)
          replacement: ${1}
        metric_relabel_configs:
        - regex: (kubernetes_io_hostname|failure_domain_beta_kubernetes_io_region|beta_kubernetes_io_os|beta_kubernetes_io_arch|beta_kubernetes_io_instance_type|failure_domain_beta_kubernetes_io_zone)
          action: labeldrop
          
...

인증서 복사하기

etcd 인증서를, 프로메테스를 설치할 monitoring 네임스페이스에, etcd-client-cert란 이름의 secret로 복사한다.

POD_NAME=$(kubectl get pods -o=jsonpath='{.items[0].metadata.name}' -l component=kube-apiserver -n kube-system)

kubectl create secret generic etcd-client-cert -n monitoring \
  --from-literal=etcd-ca="$(kubectl exec $POD_NAME -n kube-system -- cat /etc/kubernetes/pki/etcd/ca.crt)" \
  --from-literal=etcd-client="$(kubectl exec $POD_NAME -n kube-system -- cat /etc/kubernetes/pki/etcd/healthcheck-client.crt)" \
  --from-literal=etcd-client-key="$(kubectl exec $POD_NAME -n kube-system -- cat /etc/kubernetes/pki/etcd/healthcheck-client.key)"

helm으로 prometheus-operator 설치하기

helm 설치 명령어로, proemtheus-operator를 설치한다. 수정한 설정값을 적용하기 위해서 --values values.yaml 옵션을 사용한다.

helm install stable/prometheus-operator --name mon --namespace monitoring --values values.yaml --tls

참고 사항

helm으로 prometheus-operator를 삭제할때, crd는 자동으록 삭제되지 않는다. 아래 명령어로 직접 삭제해야한다.

kubectl delete --ignore-not-found customresourcedefinitions \
  prometheuses.monitoring.coreos.com \
  servicemonitors.monitoring.coreos.com \
  podmonitors.monitoring.coreos.com \
  alertmanagers.monitoring.coreos.com \
  prometheusrules.monitoring.coreos.com

참고 링크

  • https://github.com/helm/charts/tree/master/stable/prometheus-operator
  • https://github.com/kubernetes-monitoring/kubernetes-mixin
  • https://github.com/etcd-io/etcd/blob/master/Documentation/op-guide/monitoring.md

Helm v2

Helm

Helm은 쿠버네티스 패키지 관리 툴이다. chart라고 부르는, 이미 만들어 놓은 패키지 명세서를 이용해서 손쉽게 애플리케이션을 배포하고 관리할 수 있다.

사용의 편의성을 제공하기는 하지만, v2까지는 권한 문제로 인해서 약간의 불편한 점이 있다.

멀티 테넌시 환경의 쿠버네티스 클러스터에서 사용할 경우, 각 사용자의 권한별로 리소스 접근을 제어하기가 힘들다. 네임스페이스별로 tiller를 설치하고, 인증서를 관리할 수 있지만, 상당히 불편하다. 이러한 문제의 근본적인 이유는 패키지 설치를 실행하는 사용자의 권한으로 리소스를 설치하는 것이 아니라, tiller가 가진 권한으로 리소스가 설치되기 때문이다. 즉, 나에게 권한이 없어도, tiller에 권한이 있다면, 내 권한 밖의 리소스를 제어할 수 있는것이다.

다행히도 새로 만들어진 v3 부터는 이러한 문제가 해결될 것으로 보인다.

이 글을 쓰는 시점에서는 아직 v3가 정식 릴리즈 되지 않았다. 그래서 어쩔 수 없이 v2를 사용하였고, v2을 멀티 테넌시 환경에서 사용하기 쉽도록 하기 위해서 kubeapps을 사용했다.

Kubeapps

Kubeapps는 쿠버네티스트 클러스터에 애플리케이션을 배포하고 관리할 수 있게 도와주는 웹 기반의 UI 애플리케이션이다. Kubeapps는 ‘helm chart’를 사용할 수 있을 뿐 아니라, 사용자 기반의 권한 제어 기능도 제공한다.

준비물

  • RBAC 기반의 쿠버네티스 클러스터
  • OIDC Provider + 쿠버네티스 연동

Helm 설치하기

Helm 설치

helm을 설치한다.

개발 환경이 mac이라서 brew를 사용해서 간단히 설치하였다. 환경이 다르다면, helm 문서를 참고하길 바란다.

$ brew install kubernetes-helm

Using SSL/TLS Between Helm and Tiller

helm v2를 사용려면, 쿠버네티스 클러스터에 Tiller가 설치되어 있어야한다. 기본값으로 Tiller를 설치할 경우 보안상의 문제가 있기때문에 TLS 인증서를 사용하는 형태로 설치한다.

CA 만들기

openssl 툴을 이용해서, CA를 생성한다.

CA용 개인키를 생성한다.

$ openssl genrsa -out ./ca.key.pem 4096
Generating RSA private key, 4096 bit long modulus
..........................++
.........................................++
e is 65537 (0x010001)

CA용 인증서를 생성한다.

$ openssl req -key ca.key.pem -new -x509 -days 7300 -sha256 -out ca.cert.pem -extensions v3_ca
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:KR
State or Province Name (full name) [Some-State]:Gyeonggi-do
Locality Name (eg, city) []:Seongnam
Organization Name (eg, company) [Internet Widgits Pty Ltd]:tiller
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:tiller
Email Address []:tiller@example.com

이렇게 생성한 CA를 이용해서, Tiller와 Helm client을 인증서를 만들것이다.

Tiller 인증서 만들기

Tiller용 개인키를 생성한다.

$ openssl genrsa -out ./tiller.key.pem 4096
Generating RSA private key, 4096 bit long modulus
..........................................................++
.................................++
e is 65537 (0x010001)

Tiller용 인증서를 생성한다.

$ openssl req -key tiller.key.pem -new -sha256 -out tiller.csr.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:KR
State or Province Name (full name) [Some-State]:Gyeonggi-do
Locality Name (eg, city) []:Seongnam
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Tiller Server
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:tiller-server
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:

Tiller용 인증서를 CA의 인증서로 서명한다.

$ openssl x509 -req -CA ca.cert.pem -CAkey ca.key.pem -CAcreateserial -in tiller.csr.pem -out tiller.cert.pem -days 365
Signature ok
subject=C = KR, ST = Gyeonggi-do, L = Seongnam, O = Tiller Server, CN = tiller-server
Getting CA Private Key

Helm client 인증서 만들기

Helm client용 개인키를 생성한다.

$ openssl genrsa -out ./helm.key.pem 4096
  Generating RSA private key, 4096 bit long modulus
  ..................................++
  ......................................++
  e is 65537 (0x010001)

Helm client용 인증서를 생성한다.

$ openssl req -key helm.key.pem -new -sha256 -out helm.csr.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:KR
State or Province Name (full name) [Some-State]:Gyeonggi-do
Locality Name (eg, city) []:Seongnam
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Helm Client
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:helm-client
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:

Helm client용 인증서를 CA의 인증서로 서명한다.

openssl x509 -req -CA ca.cert.pem -CAkey ca.key.pem -CAcreateserial -in helm.csr.pem -out helm.cert.pem -days 365
Signature ok
subject=C = KR, ST = Gyeonggi-do, L = Seongnam, O = Helm Client, CN = helm-client
Getting CA Private Key

서비스 어카운트 만들기

tiller가 사용할 serviceaccount를 생성하고, cluster-admin 클러스터롤(ClusterRole)을 바인딩해준다.

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system
EOF

Tiller 설치하기

생성한 인증서와 서비스어카운트를 지정하여, ‘Tiller’를 설치한다.

설치 명령어는 다음과 같다.

$ helm init --service-account tiller --tiller-tls --tiller-tls-cert ./tiller.cert.pem --tiller-tls-key ./tiller.key.pem --tiller-tls-verify --tls-ca-cert ca.cert.pem

설치가 완료되면, helm ls명령어를 실행해 본다. 다음과 같은 에러가 발생하면 정상적으로 설치한 것이다.

$ helm ls
Error: transport is closing

Helm client 설정하기

설치한 ‘Tiller’는 TLS 로 보호받고 있기 때문에, helm 클라이언트로 접근하려면 인증서를 지정해 줘야한다.

가장 간단한 방법은 인증서 정보를 모두 지정해 주는 것이다.

$ helm ls --tls --tls-ca-cert ca.cert.pem --tls-cert helm.cert.pem --tls-key helm.key.pem

매번 인증서를 지정해주는것은 불편하기 때문에, 인증서를 $HELM_HOME에 복사해 놓으면 좀 더 쉽게 사용할 수 있다.

$ export HELM_HOME=/Users/kangwoo/.helm
$ cp ca.cert.pem $HELM_HOME/ca.pem
$ cp helm.cert.pem $HELM_HOME/cert.pem
$ cp helm.key.pem $HELM_HOME/key.pem

인증서를 $HELM_HOME에 복사하였다면, helm을 실행할때 --tls만 붙여주면 된다.

$ helm ls --tls

Kubeapps 설치하기

helm을 사용해서 kubeapps를 설치할 것이다. tiller의 tls와 OIDC 인증을 위해서 values.yaml값을 수정해 준다.

  • App Version: v1.5.0
  • Chart Version: 2.1.2

Ingress 설정하기

ingress를 사용하기 위해서 설정해준다.

ingress.enabled를 true로 변경하고, ingress.hosts.name을 설정한다.

ingress:
  enabled: true
...
  hosts:
    - name: kubeapps.xxx.com
      path: /

tiller Proxy tls 설정하기

tillerProxy.tls.verify을 true로 변경하고, tillerProxy.tls.catillerProxy.tls.certtillerProxy.tls.key 값을 설정한다.

  • tillerProxy.tls.ca=”$(cat ca.cert.pem)”
  • tillerProxy.tls.cert=”$(cat helm.cert.pem)”
  • tillerProxy.tls.key=”$(cat helm.key.pem)”
...
tillerProxy:
  replicaCount: 2
  image:
    registry: docker.io
    repository: bitnami/kubeapps-tiller-proxy
    tag: 1.5.0-r0
  service:
    port: 8080
  host: tiller-deploy.kube-system:44134
  tls: 
    ca: |-
      -----BEGIN CERTIFICATE-----
      MIIF1zCCA7+gAwIBAgIJAPrXoUYpgyDEMA0GCSqGSIb3DQEBCwUAMIGBMQswCQYD
      ...
      -----END CERTIFICATE-----
    cert: |-
      -----BEGIN CERTIFICATE-----
      MIIFYDCCA0gCCQCnyMMmF4lKHzANBgkqhkiG9w0BAQsFADCBgTELMAkGA1UEBhMC
      ...
      -----END CERTIFICATE-----
    key: |-
      -----BEGIN RSA PRIVATE KEY-----
      MIIJKgIBAAKCAgEA3Mb/4vvMqMVouSV2wLOX94R2okP0rcswLBUGR66asD1CLIa/
      ...
      -----END RSA PRIVATE KEY-----
    verify: true
...

OIDC 인증 활성화하기

쿠버네티스 클러스터에서 사용하는 OIDC Provider를 authProxy에 설정해준다. 그래야 kubeapps 웹 UI 화면에 접속할 때, 로그인을 할 수 있고 해당 토큰으로 kubeapps를 사용할 수 있다.

authProxy.enabled을 true로 변경하고, authProxy.discoveryURLauthProxy.clientIDauthProxy.clientSecret의 값을 설정한 후, authProxy.additionalFlags에 --secure-cookie=false--scopes=openid groups email을 추가해 준다.

...
authProxy:
  # Set to true to enable the OIDC proxy
  enabled: true
  # Image used for the proxy
  image:
    registry: docker.io
    repository: bitnami/keycloak-gatekeeper
    tag: 2.3.0-r1
  # Mandatory parametes
  discoveryURL: https://REPLACE_URL
  clientID: REPLACE_CLIENT_ID
  clientSecret: REPLACE_CLIENT_SECRET
  # Additional flags for Keycloak-Gatekeeper
  additionalFlags:
    - --secure-cookie=false
    - --scopes=openid groups email
$ helm install -f values.yaml bitnami/kubeapps \
  --namespace kubeapps --name kubeapps \
  --tls


NAME:   kubeapps
LAST DEPLOYED: Tue Sep 10 19:45:04 2019
NAMESPACE: kubeapps
STATUS: DEPLOYED

RESOURCES:
==> v1/ConfigMap
NAME                                DATA  AGE
kubeapps-frontend-config            1     1s
kubeapps-internal-dashboard-config  2     1s

==> v1/Pod(related)
NAME                                                        READY  STATUS             RESTARTS  AGE
kubeapps-86cd959cc8-knbwk                                   0/2    ContainerCreating  0         1s
kubeapps-86cd959cc8-mtgqc                                   0/2    Pending            0         1s
kubeapps-internal-apprepository-controller-77cc98bcc-8s5dv  0/1    ContainerCreating  0         1s
kubeapps-internal-chartsvc-7fc7bc4fc5-4ssdx                 0/1    ContainerCreating  0         1s
kubeapps-internal-chartsvc-7fc7bc4fc5-n4x65                 0/1    ContainerCreating  0         1s
kubeapps-internal-dashboard-5df4c549b9-dckw2                0/1    Pending            0         1s
kubeapps-internal-dashboard-5df4c549b9-qlvjl                0/1    ContainerCreating  0         1s
kubeapps-internal-tiller-proxy-68c5cb8998-fnfbg             0/1    Pending            0         1s
kubeapps-internal-tiller-proxy-68c5cb8998-rgc6x             0/1    ContainerCreating  0         1s
kubeapps-mongodb-85f58746ff-d6p5g                           0/1    ContainerCreating  0         1s

==> v1/Secret
NAME                            TYPE    DATA  AGE
kubeapps-internal-tiller-proxy  Opaque  3     1s

==> v1/Service
NAME                            TYPE       CLUSTER-IP      EXTERNAL-IP  PORT(S)    AGE
kubeapps                        ClusterIP  172.30.67.115   <none>       80/TCP     1s
kubeapps-internal-chartsvc      ClusterIP  172.31.216.84   <none>       8080/TCP   1s
kubeapps-internal-dashboard     ClusterIP  172.30.205.251  <none>       8080/TCP   1s
kubeapps-internal-tiller-proxy  ClusterIP  172.31.13.240   <none>       8080/TCP   1s
kubeapps-mongodb                ClusterIP  172.30.33.149   <none>       27017/TCP  1s

==> v1/ServiceAccount
NAME                                        SECRETS  AGE
kubeapps-internal-apprepository-controller  1        1s
kubeapps-internal-tiller-proxy              1        1s

==> v1beta1/Deployment
NAME              READY  UP-TO-DATE  AVAILABLE  AGE
kubeapps-mongodb  0/1    1           0          1s

==> v1beta1/Ingress
NAME      HOSTS                                 ADDRESS  PORTS  AGE
kubeapps  kubeapps.xxx.com  80       1s

==> v1beta1/Role
NAME                                        AGE
kubeapps-internal-apprepository-controller  1s
kubeapps-internal-tiller-proxy              1s
kubeapps-repositories-read                  1s
kubeapps-repositories-write                 1s

==> v1beta1/RoleBinding
NAME                                        AGE
kubeapps-internal-apprepository-controller  1s
kubeapps-internal-tiller-proxy              1s

==> v1beta2/Deployment
NAME                                        READY  UP-TO-DATE  AVAILABLE  AGE
kubeapps                                    0/2    2           0          1s
kubeapps-internal-apprepository-controller  0/1    1           0          1s
kubeapps-internal-chartsvc                  0/2    2           0          1s
kubeapps-internal-dashboard                 0/2    2           0          1s
kubeapps-internal-tiller-proxy              0/2    2           0          1s


NOTES:
** Please be patient while the chart is being deployed **

Tip:

  Watch the deployment status using the command: kubectl get pods -w --namespace kubeapps

Kubeapps can be accessed via port 80 on the following DNS name from within your cluster:

   kubeapps.kubeapps.svc.cluster.local

To access Kubeapps from outside your K8s cluster, follow the steps below:

1. Get the Kubeapps URL and associate Kubeapps hostname to your cluster external IP:

   export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
   echo "Kubeapps URL: http://kubeapps.xxx.com/"
   echo "$CLUSTER_IP  kubeapps.xxx.com" | sudo tee -a /etc/hosts

2. Open a browser and access Kubeapps using the obtained URL.

Helm v2

 7 분 소요

Helm

Helm은 쿠버네티스 패키지 관리 툴이다. chart라고 부르는, 이미 만들어 놓은 패키지 명세서를 이용해서 손쉽게 애플리케이션을 배포하고 관리할 수 있다.

사용의 편의성을 제공하기는 하지만, v2까지는 권한 문제로 인해서 약간의 불편한 점이 있다.

멀티 테넌시 환경의 쿠버네티스 클러스터에서 사용할 경우, 각 사용자의 권한별로 리소스 접근을 제어하기가 힘들다. 네임스페이스별로 tiller를 설치하고, 인증서를 관리할 수 있지만, 상당히 불편하다. 이러한 문제의 근본적인 이유는 패키지 설치를 실행하는 사용자의 권한으로 리소스를 설치하는 것이 아니라, tiller가 가진 권한으로 리소스가 설치되기 때문이다. 즉, 나에게 권한이 없어도, tiller에 권한이 있다면, 내 권한 밖의 리소스를 제어할 수 있는것이다.

다행히도 새로 만들어진 v3 부터는 이러한 문제가 해결될 것으로 보인다.

이 글을 쓰는 시점에서는 아직 v3가 정식 릴리즈 되지 않았다. 그래서 어쩔 수 없이 v2를 사용하였고, v2을 멀티 테넌시 환경에서 사용하기 쉽도록 하기 위해서 kubeapps을 사용했다.

Kubeapps

Kubeapps는 쿠버네티스트 클러스터에 애플리케이션을 배포하고 관리할 수 있게 도와주는 웹 기반의 UI 애플리케이션이다. Kubeapps는 ‘helm chart’를 사용할 수 있을 뿐 아니라, 사용자 기반의 권한 제어 기능도 제공한다.

Applications
Catalog

준비물

  • RBAC 기반의 쿠버네티스 클러스터
  • OIDC Provider + 쿠버네티스 연동

Helm 설치하기

Helm 설치

‘helm’을 설치한다.

개발 환경이 mac이라서 brew를 사용해서 간단히 설치하였다. 환경이 다르다면, helm 문서를 참고하길 바란다.

$ brew install kubernetes-helm

Using SSL/TLS Between Helm and Tiller

‘helm’ v2를 사용려면, 쿠버네티스 클러스터에 Tiller가 설치되어 있어야한다. 기본값으로 Tiller를 설치할 경우 보안상의 문제가 있기때문에 TLS 인증서를 사용하는 형태로 설치한다.

CA 만들기

openssl 툴을 이용해서, CA를 생성한다.

  • CA용 개인키를 생성한다.$ openssl genrsa -out ./ca.key.pem 4096 Generating RSA private key, 4096 bit long modulus ..........................++ .........................................++ e is 65537 (0x010001)
  • CA용 인증서를 생성한다.$ openssl req -key ca.key.pem -new -x509 -days 7300 -sha256 -out ca.cert.pem -extensions v3_ca You are about to be asked to enter information that will be incorporated into your certificate request. What you are about to enter is what is called a Distinguished Name or a DN. There are quite a few fields but you can leave some blank For some fields there will be a default value, If you enter '.', the field will be left blank. ----- Country Name (2 letter code) [AU]:KR State or Province Name (full name) [Some-State]:Gyeonggi-do Locality Name (eg, city) []:Seongnam Organization Name (eg, company) [Internet Widgits Pty Ltd]:tiller Organizational Unit Name (eg, section) []: Common Name (e.g. server FQDN or YOUR name) []:tiller Email Address []:tiller@example.com 이렇게 생성한 CA를 이용해서, Tiller와 Helm client을 인증서를 만들것이다.

Tiller 인증서 만들기

  • Tiller용 개인키를 생성한다.
$ openssl genrsa -out ./tiller.key.pem 4096
Generating RSA private key, 4096 bit long modulus
..........................................................++
.................................++
e is 65537 (0x010001)
  • Tiller용 인증서를 생성한다.
$ openssl req -key tiller.key.pem -new -sha256 -out tiller.csr.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:KR
State or Province Name (full name) [Some-State]:Gyeonggi-do
Locality Name (eg, city) []:Seongnam
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Tiller Server
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:tiller-server
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:
  • Tiller용 인증서를 CA의 인증서로 서명한다.
$ openssl x509 -req -CA ca.cert.pem -CAkey ca.key.pem -CAcreateserial -in tiller.csr.pem -out tiller.cert.pem -days 365
Signature ok
subject=C = KR, ST = Gyeonggi-do, L = Seongnam, O = Tiller Server, CN = tiller-server
Getting CA Private Key

Helm client 인증서 만들기

  • Helm client용 개인키를 생성한다.
$ openssl genrsa -out ./helm.key.pem 4096
  Generating RSA private key, 4096 bit long modulus
  ..................................++
  ......................................++
  e is 65537 (0x010001)
  • Helm client용 인증서를 생성한다.
openssl req -key helm.key.pem -new -sha256 -out helm.csr.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:KR
State or Province Name (full name) [Some-State]:Gyeonggi-do
Locality Name (eg, city) []:Seongnam
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Helm Client
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:helm-client
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:
  • Helm client용 인증서를 CA의 인증서로 서명한다.
$ openssl x509 -req -CA ca.cert.pem -CAkey ca.key.pem -CAcreateserial -in helm.csr.pem -out helm.cert.pem -days 365
Signature ok
subject=C = KR, ST = Gyeonggi-do, L = Seongnam, O = Helm Client, CN = helm-client
Getting CA Private Key

서비스 어카운트 만들기

‘Tiller’가 사용할 serviceaccount를 생성하고, cluster-admin 클러스터롤(ClusterRole)을 바인딩해준다.

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system
EOF

Tiller 설치하기

생성한 인증서와 서비스어카운트를 지정하여, ‘Tiller’를 설치한다.

설치 명령어는 다음과 같다.

$ helm init --service-account tiller --tiller-tls --tiller-tls-cert ./tiller.cert.pem --tiller-tls-key ./tiller.key.pem --tiller-tls-verify --tls-ca-cert ca.cert.pem

설치가 완료되면, helm ls명령어를 실행해 본다. 다음과 같은 에러가 발생하면 정상적으로 설치한 것이다.

$ helm ls
Error: transport is closing

Helm client 설정하기

설치한 ‘Tiller’는 TLS 로 보호받고 있기 때문에, helm 클라이언트로 접근하려면 인증서를 지정해 줘야한다.

가장 간단한 방법은 인증서 정보를 모두 지정해 주는 것이다.

$ helm ls --tls --tls-ca-cert ca.cert.pem --tls-cert helm.cert.pem --tls-key helm.key.pem

매번 인증서를 지정해주는것은 불편하기 때문에, 인증서를 $HELM_HOME에 복사해 놓으면 좀 더 쉽게 사용할 수 있다.

$ export HELM_HOME=/Users/kangwoo/.helm
$ cp ca.cert.pem $HELM_HOME/ca.pem
$ cp helm.cert.pem $HELM_HOME/cert.pem
$ cp helm.key.pem $HELM_HOME/key.pem

인증서를 $HELM_HOME에 복사하였다면, helm을 실행할때 --tls만 붙여주면 된다.

$ helm ls --tls

Kubeapps 설치하기

helm을 사용해서 kubeapps를 설치할 것이다. tiller의 tls와 OIDC 인증을 위해서 values.yaml값을 수정해 준다.

  • App Version: v1.5.0
  • Chart Version: 2.1.2

ingress 설절하기

ingress를 사용하기 위해서 설정해준다.

ingress.enabled를 true로 변경하고, ingress.hosts.name을 설정한다.

ingress:
  enabled: true
...
  hosts:
    - name: kubeapps.xxx.com
      path: /

tillerProxy tls 설정하기

tillerProxy.tls.verify을 true로 변경하고, tillerProxy.tls.catillerProxy.tls.certtillerProxy.tls.key 값을 설정한다.

  • tillerProxy.tls.ca=”$(cat ca.cert.pem)”
  • tillerProxy.tls.cert=”$(cat helm.cert.pem)”
  • tillerProxy.tls.key=”$(cat helm.key.pem)”
...
tillerProxy:
  replicaCount: 2
  image:
    registry: docker.io
    repository: bitnami/kubeapps-tiller-proxy
    tag: 1.5.0-r0
  service:
    port: 8080
  host: tiller-deploy.kube-system:44134
  tls: 
    ca: |-
      -----BEGIN CERTIFICATE-----
      MIIF1zCCA7+gAwIBAgIJAPrXoUYpgyDEMA0GCSqGSIb3DQEBCwUAMIGBMQswCQYD
      ...
      -----END CERTIFICATE-----
    cert: |-
      -----BEGIN CERTIFICATE-----
      MIIFYDCCA0gCCQCnyMMmF4lKHzANBgkqhkiG9w0BAQsFADCBgTELMAkGA1UEBhMC
      ...
      -----END CERTIFICATE-----
    key: |-
      -----BEGIN RSA PRIVATE KEY-----
      MIIJKgIBAAKCAgEA3Mb/4vvMqMVouSV2wLOX94R2okP0rcswLBUGR66asD1CLIa/
      ...
      -----END RSA PRIVATE KEY-----
    verify: true
...

OIDC 인증 활성화하기

쿠버네티스 클러스터에서 사용하는 OIDC Provider를 authProxy에 설정해준다. 그래야 kubeapps 웹 UI 화면에 접속할 때, 로그인을 할 수 있고 해당 토큰으로 kubeapps를 사용할 수 있다.

authProxy.enabled을 true로 변경하고, authProxy.discoveryURLauthProxy.clientIDauthProxy.clientSecret의 값을 설정한 후, authProxy.additionalFlags에 --secure-cookie=false--scopes=openid groups email을 추가해 준다.

...
authProxy:
  # Set to true to enable the OIDC proxy
  enabled: true
  # Image used for the proxy
  image:
    registry: docker.io
    repository: bitnami/keycloak-gatekeeper
    tag: 2.3.0-r1
  # Mandatory parametes
  discoveryURL: https://REPLACE_URL
  clientID: REPLACE_CLIENT_ID
  clientSecret: REPLACE_CLIENT_SECRET
  # Additional flags for Keycloak-Gatekeeper
  additionalFlags:
    - --secure-cookie=false
    - --scopes=openid groups email
$ helm install -f values.yaml bitnami/kubeapps \
  --namespace kubeapps --name kubeapps \
  --tls


NAME:   kubeapps
LAST DEPLOYED: Tue Sep 10 19:45:04 2019
NAMESPACE: kubeapps
STATUS: DEPLOYED

RESOURCES:
==> v1/ConfigMap
NAME                                DATA  AGE
kubeapps-frontend-config            1     1s
kubeapps-internal-dashboard-config  2     1s

==> v1/Pod(related)
NAME                                                        READY  STATUS             RESTARTS  AGE
kubeapps-86cd959cc8-knbwk                                   0/2    ContainerCreating  0         1s
kubeapps-86cd959cc8-mtgqc                                   0/2    Pending            0         1s
kubeapps-internal-apprepository-controller-77cc98bcc-8s5dv  0/1    ContainerCreating  0         1s
kubeapps-internal-chartsvc-7fc7bc4fc5-4ssdx                 0/1    ContainerCreating  0         1s
kubeapps-internal-chartsvc-7fc7bc4fc5-n4x65                 0/1    ContainerCreating  0         1s
kubeapps-internal-dashboard-5df4c549b9-dckw2                0/1    Pending            0         1s
kubeapps-internal-dashboard-5df4c549b9-qlvjl                0/1    ContainerCreating  0         1s
kubeapps-internal-tiller-proxy-68c5cb8998-fnfbg             0/1    Pending            0         1s
kubeapps-internal-tiller-proxy-68c5cb8998-rgc6x             0/1    ContainerCreating  0         1s
kubeapps-mongodb-85f58746ff-d6p5g                           0/1    ContainerCreating  0         1s

==> v1/Secret
NAME                            TYPE    DATA  AGE
kubeapps-internal-tiller-proxy  Opaque  3     1s

==> v1/Service
NAME                            TYPE       CLUSTER-IP      EXTERNAL-IP  PORT(S)    AGE
kubeapps                        ClusterIP  172.30.67.115   <none>       80/TCP     1s
kubeapps-internal-chartsvc      ClusterIP  172.31.216.84   <none>       8080/TCP   1s
kubeapps-internal-dashboard     ClusterIP  172.30.205.251  <none>       8080/TCP   1s
kubeapps-internal-tiller-proxy  ClusterIP  172.31.13.240   <none>       8080/TCP   1s
kubeapps-mongodb                ClusterIP  172.30.33.149   <none>       27017/TCP  1s

==> v1/ServiceAccount
NAME                                        SECRETS  AGE
kubeapps-internal-apprepository-controller  1        1s
kubeapps-internal-tiller-proxy              1        1s

==> v1beta1/Deployment
NAME              READY  UP-TO-DATE  AVAILABLE  AGE
kubeapps-mongodb  0/1    1           0          1s

==> v1beta1/Ingress
NAME      HOSTS                                 ADDRESS  PORTS  AGE
kubeapps  kubeapps.xxx.com  80       1s

==> v1beta1/Role
NAME                                        AGE
kubeapps-internal-apprepository-controller  1s
kubeapps-internal-tiller-proxy              1s
kubeapps-repositories-read                  1s
kubeapps-repositories-write                 1s

==> v1beta1/RoleBinding
NAME                                        AGE
kubeapps-internal-apprepository-controller  1s
kubeapps-internal-tiller-proxy              1s

==> v1beta2/Deployment
NAME                                        READY  UP-TO-DATE  AVAILABLE  AGE
kubeapps                                    0/2    2           0          1s
kubeapps-internal-apprepository-controller  0/1    1           0          1s
kubeapps-internal-chartsvc                  0/2    2           0          1s
kubeapps-internal-dashboard                 0/2    2           0          1s
kubeapps-internal-tiller-proxy              0/2    2           0          1s


NOTES:
** Please be patient while the chart is being deployed **

Tip:

  Watch the deployment status using the command: kubectl get pods -w --namespace kubeapps

Kubeapps can be accessed via port 80 on the following DNS name from within your cluster:

   kubeapps.kubeapps.svc.cluster.local

To access Kubeapps from outside your K8s cluster, follow the steps below:

1. Get the Kubeapps URL and associate Kubeapps hostname to your cluster external IP:

   export CLUSTER_IP=$(minikube ip) # On Minikube. Use: `kubectl cluster-info` on others K8s clusters
   echo "Kubeapps URL: http://kubeapps.xxx.com/"
   echo "$CLUSTER_IP  kubeapps.xxx.com" | sudo tee -a /etc/hosts

2. Open a browser and access Kubeapps using the obtained URL.

ingress에 설정한 주소로 접속하면 kubeapps를 사용할 수 있다.

참고 문서

쿠버네티스 네임스페이스가 삭제되지 않을 때 강제 삭제하기

문제

가끔식 문제가 발생하여, 네임스페이스(namespace)를 삭제할때, 상태만 Terminating으로 변하고, 계속 기다려도 삭제가 되지 않는 경우가 있다.

이럴 경우에는 네임스페이스의 finalizers를 제거해 주면 된다. (하지만 정상작으로 삭제될때까지 기다리는게 가장 좋다)

해결 방법

foo라는 네임스페이스가 있다고 가정한다.

다음과 같은 명령어로 네임스페이스 정의 내역을 json 파일로 저장한다.

$ kubectl get namespace foo -o json > foo.json

foo.json 파일을 영어서 finalizers 부분에 있는 kubernetes 값을 삭제하고, 저장한다.

그런 다음 쿠베 프락시를 실행한다. 쿠버네티스 api를 호출할 예정인데, 인증 토큰이 필요하다. kubectl proxy를 이용하면, 저장되어 있는 인증토큰을 자동으로 이용한다.

$ kubectl proxy
Starting to serve on 127.0.0.1:8001

다른 터미널을 열어서 쿠버네티스 api를 호출한다. 다음과 같이 api를 호출하면 변경된 finalizers 부분이 쿠버네티스에 반영된다.

curl -k -H "Content-Type: application/json" -X PUT --data-binary @foo.json http://127.0.0.1:8001/api/v1/namespaces/foo/finalize