This page looks best with JavaScript enabled

Kubernetes kubelet 怎麼抓住你的 secret

 ·  ☕ 10 min read

首先本文所有的 source code 基於 kubernetes 1.19 版本,所有 source code 為了版面的整潔會精簡掉部分 log 相關的程式碼,僅保留核心邏輯,如果有見解錯誤的地方,還麻煩觀看本文的大大們提出,感謝!

本篇文章跟前一章非常相似,如果還沒有看過Kubernetes kubelet 怎麼抓住你的 configmap可以去參考看看!

本章節的內容同樣需要先了解 Kubernetes kubelet cacheBasedManager 現在才知道以及Kubernetes kubelet cacheBasedManager 好喜歡 objectcache 的原因,會比較容易了解本章節的重點呦!

manger interface

Kubernetes 先定義出取得 secret 資料的行為,有以下三點。

  1. 當 pod spec 內寫到需要 secret 的時候如何註冊需要的 secret 。
  2. 曾經 pod spce 內有需要 xxx secret 現在不用的時候,需要反註冊 secret 。
  3. 能透過 secret 的 namespace 與 name 取得對應的資料。
    source. code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Manager manages Kubernetes secrets. This includes retrieving
// secrets or registering/unregistering them via Pods.
type Manager interface {
	
	GetSecret(namespace, name string) (*v1.Secret, error)

	
	RegisterPod(pod *v1.Pod)

	
	UnregisterPod(pod *v1.Pod)
}

Secret manager

secretManager 這個物件實作了 manger interface,物件裡面的屬性也相當的簡單只有一個我們在前兩章節介紹過的 Kubernetes kubelet configmap & secret 與 cacheBasedManager 激情四射 ,也就是說secret Manager 極度依賴 manager.Manager 的實作。
source code

1
2
3
type secretManager struct {
	manager manager.Manager				//用以處理 pod 註冊 configmap/secret ,建立 reflector 等相關操作。
}

[color=#f41dc9]TIPS:
如果還不是很了解的 manager.Manager 的實作方式,強烈建議複習一下Kubernetes kubelet configmap & secret 與 cacheBasedManager 激情四射

new function

了解完secretManager的資料結構後,我們接著要來看看在初始化這個物件時需要什麼東西吧!

source code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// NewWatchingSecretManager creates a manager that keeps a cache of all secrets
// necessary for registered pods.
// It implements the following logic:
// - whenever a pod is created or updated, we start individual watches for all
//   referenced objects that aren't referenced from other registered pods
// - every GetObject() returns a value from local cache propagated via watches
func NewWatchingSecretManager(kubeClient clientset.Interface) Manager {
	//建立一個 lister  watcher function ,主要用來監聽 kubernetes secret 的變化
	//list 的條件為透過 client go 的 corev1 secret list&watch 相關的資訊。
	//實作上依賴注入的 kubernetes client interface ,這個 interface 功能非常多有機會之後再來看
	listSecret := func(namespace string, opts metav1.ListOptions) (runtime.Object, error) {
		return kubeClient.CoreV1().Secrets(namespace).List(context.TODO(), opts)
	}
	watchSecret := func(namespace string, opts metav1.ListOptions) (watch.Interface, error) {
		return kubeClient.CoreV1().Secrets(namespace).Watch(context.TODO(), opts)
	}

	//還記得之前在挖掘 kubernetes controller operator 的過程嗎? 需要知道要觀測對象的型態
	//在這裡就是 secret
	newSecret := func() runtime.Object {
		return &v1.Secret{}
	}
	//secret 其中有個欄位是 Immutable ,當 reflector 觀察到 secret 會透過這個 function 
	// 檢查 secret 是不是 Immutable 的狀態,若是為 Immutable 的狀態就會停止該物件的 reflector 
	isImmutable := func(object runtime.Object) bool {
		if secret, ok := object.(*v1.Secret); ok {
			return secret.Immutable != nil && *secret.Immutable
		}
		return false
	}

	gr := corev1.Resource("secret")
	//回傳 secret Manger 的物件,其中 manager 的實作為 WatchBasedManager
	return &secretManager{
		manager: manager.NewWatchBasedManager(listSecret, watchSecret, newSecret, isImmutable, gr, getSecretNames),
	}

}

RegisterPod/UnregisterPod

configMapManager 這裡就是無腦的把任務交給 manager 的 RegisterPod function 或是 UnregisterPod function ,裡面的實作簡單的提一下細節可以回去複習Kubernetes kubelet configmap & secret 與 cacheBasedManager 激情四射

source code

  • RegisterPod function
    • pod spec 內用到 secret 的地方會透過 cacheBasedManager 去解析並且生成對應的 reflector
  • UnregisterPod function
    • pod spec 內用到 secret 的地方會透過 cacheBasedManager 去解析並且刪除對應的 reflector
1
2
3
4
5
6
7
func (s *secretManager) RegisterPod(pod *v1.Pod) {
	s.manager.RegisterPod(pod)
}

func (s *secretManager) UnregisterPod(pod *v1.Pod) {
	s.manager.UnregisterPod(pod)
}

一定有人問 kubelet 如何取得 secret 資料,中間過程非常非常非常的長,整個調用鍊生命週期也有點小雜亂,之後有時間會來整理一下 kubelet 的調用鍊,本小節只簡單的帶出 kubelet 的 secret manager 如何註/反註冊 pod spec 中用到的 secret 欄位。

底下這個 function 會觸發的時機點就先想像成, kubelet 會先把這一段時間有變化的 pod 都透過這個 function 丟進來。前面怎麼走到這一步的先不要管他…太複雜了會迷失方向xD
source code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// updatePodsInternal replaces the given pods in the current state of the
// manager, updating the various indices. The caller is assumed to hold the
// lock.
func (pm *basicManager) updatePodsInternal(pods ...*v1.Pod) {
	//遞迴所有變化的 pod
	for _, pod := range pods {
		...
		//一般來說...前面已經會把 secretManager 設定好
		if pm.secretManager != nil {
			// 如果 pod 處於 Terminated 狀態就需要返註冊 pod status 
			if isPodInTerminatedState(pod) {
				pm.secretManager.UnregisterPod(pod)
			} else {
			// 如果 pod 處於其他狀態就需要註冊 pod status,開始解析用到的 secret 建立對應的 reflector 等等 
				pm.secretManager.RegisterPod(pod)
			}
		}
        ...

GetSecret

只剩下一個主要的 GetSecret function,只要傳入 namespace 以及要取得的 secret name 就能得到對應的 secret 物件,這邊的情境略為複雜需要舉幾個例子來說明,我們先來看實作的部分。

source code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (s *secretManager) GetSecret(namespace, name string) (*v1.Secret, error) {
	//主要是透過前幾章說的[Kubernetes kubelet configmap & secret 與 cacheBasedManager 激情四射](https://blog.jjmengze.website/posts/kubernetes/source-code/kubelet/configmapsecret/kubernetes-kubelet-cachebasedmanager/) 
	//去取得 secret 物件的資料,這邊不了解的話可以去複習一下相關連結。
	object, err := c.manager.GetObject(namespace, name)
	if err != nil {
		return nil, err
	}
	//透過 cacheBasedManager 取得的物件需要轉成對應的型態例如 secret
	if secret, ok := object.(*v1.Secret); ok {
		return secret, nil
	}
	return nil, fmt.Errorf("unexpected object type: %v", object)
}

總共有三種方法可以將 kubernetes 中設定好的 secret 掛載到 pod container 中,這三種方法分別是。

  • env
    • valueFrom
      • secretRef
  • envFrom
    • configMapRef
  • volumes
    • secret

撰寫 yaml 給 kubernetes 很簡單那實際上 kubernetes 幫我們做了什麼呢?

env - valueFrom - secret

範例是擷取自 kubernetes 官方網站,撰寫一個 yaml 檔送給 kubernetes 告訴 kubernetes 幫忙啟動一個 pod 並且建立一個 secret ,將所有 secret 定義為 pod 的環境變數。 secret 中的 key 成為 Pod 中的環境變數名稱,value 則為環境變數的數值。

Use-Case: As container environment variables

1
2
3
4
5
6
7
8
apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  USER_NAME: YWRtaW4=
  PASSWORD: MWYyZDFlMmU2N2Rm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: v1
kind: Pod
metadata:
  name: secret-test-pod
spec:
  containers:
    - name: test-container
      image: k8s.gcr.io/busybox
      command: [ "/bin/sh", "-c", "env" ]
      envFrom:
      - secretRef:
          name: mysecret
  restartPolicy: Never

話不多說我們先來看 kubelet 怎麼從 api server 經過層層關卡抓到 pod yaml 裡面寫的東西,再取得對應的 secret 。
source code

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
func (kl *Kubelet) makeEnvironmentVariables(pod *v1.Pod, container *v1.Container, podIP string, podIPs []string) ([]kubecontainer.EnvVar, error) {
    ..
    
    //儲存最後要變成 pod 的環境變數
	var result []kubecontainer.EnvVar
    
    //簡單來說就是如果 pod yaml 有啟用 EnableServiceLinks 的話就需要把同一個 namespaces 所有的 service 
    //對應的名稱、IP 與 port 以環境變數的方式除存在 serviceENV 中
	serviceEnv, err := kl.getServiceEnvVarMap(pod.Namespace, *pod.Spec.EnableServiceLinks)
    
	var (
		configMaps = make(map[string]*v1.ConfigMap)
		secrets    = make(map[string]*v1.Secret)
		tmpEnv     = make(map[string]string)
	)
	...
    
	// Env will override EnvFrom variables.
	// Process EnvFrom first then allow Env to replace existing values.
	//這裡會遞迴 yaml 裡面 container envform 的欄位
	for _, envFrom := range container.EnvFrom {
		//接著要來判斷 envform 裡面有沒有 SecretRef 這個欄位了
		switch {
        
		//如果有找到  SecretRef 這個欄位的話
		case envFrom.SecretRef != nil:
			//把 SecretRef 欄位內的資料結構拿出來為 s
			s := envFrom.SecretRef
            
			// 把 SecretRef 欄位內的名稱拿出來 ,這裡就是 secret 的名稱
			name := s.Name
            
			// 檢查 secret map 是否有處理過同樣名字的物件過
			secret, ok := secrets[name]
            
			//如果 map 找不到東西表示....我們要自己向 api server 要看相關的 secret 
			if !ok {
				//這裡用的又是 kubernetes client interface ,沒有這個 interface 什麼都做不了
				//所以這裡有錯的話就可以回家洗洗睡了xD
				if kl.kubeClient == nil {
					return result, fmt.Errorf("couldn't get secret %v/%v, no kubeClient defined", pod.Namespace, name)
				}
                
				//簡單來說就是有定義 SecretEnvSource.Optional = true,等等會看到要用來做什麼的
				optional := s.Optional != nil && *s.Optional
				
				//這邊就是用 secret manager 的 GetConfigMap function 去取得 secret 
				//所要求的參數也不多只要 namespace 與 secret 的 name 就能取的對應的資料
				secret, err = kl.secretManager.GetSecret(pod.Namespace, name)
                
				//如果在找的過程有出現錯的話,並且有定義 SecretEnvSource.Optional = true 那就不會直接噴 error 
				if err != nil {
					if errors.IsNotFound(err) && optional {
						// ignore error when marked optional
						continue
					}
					return result, err
				}
				//接著以 secret name 作為 key 以及 secret 物件的內容作為 value 存在 map 中
				//以供後續重複使用                
				configMaps[name] = configMap
			}
            
			
            
            
			//secret 中如果 key value 有不符合環境變數的資料會存在這個 slice 中                
			invalidKeys := []string{}
            
            
			//剛剛我們透過 secretManager.GetConfigMap 取的對應的 secret
            //我們的最終目的是取的 secret 對應的 key value 資料
            //這裡就用 for 迴圈遞迴 secret 資料的每一列
			for k, v := range secret.Data {
				
				if len(envFrom.Prefix) > 0 {
					k = envFrom.Prefix + k
				}
				//IsEnvVarName 判斷 configmap 資料的 key value 其中的 key 是否為有效的環境變變數名稱。
				if errMsgs := utilvalidation.IsEnvVarName(k); len(errMsgs) != 0 {
					//secret 中如果 key value 有不符合環境變數的資料會存在這個 slice 中 
					invalidKeys = append(invalidKeys, k)
					continue
				}
				//把沒問題的資料存在暫存的環境變數 map 
				tmpEnv[k] = v
			}

			//將有問題的 configmap 資料做整理加入一些 log 資訊讓使用者更好閱讀
			if len(invalidKeys) > 0 {
				//不知道為什麼要 sort xDDD            
				sort.Strings(invalidKeys)
                
				kl.recorder.Eventf(pod, v1.EventTypeWarning, "InvalidEnvironmentVariableNames", "Keys [%s] from the EnvFrom secret %s/%s were skipped since they are considered invalid environment variable names.", strings.Join(invalidKeys, ", "), pod.Namespace, name)
			}
           

//環境變數的正規表達式
const envVarNameFmt = "[-._a-zA-Z][-._a-zA-Z0-9]*"
var envVarNameRegexp = regexp.MustCompile("^" + envVarNameFmt + "$")

//IsEnvVarName 測試 secret 資料的 key value 其中的 key 是否為有效的環境變變數名稱。
func IsEnvVarName(value string) []string {
	var errs []string
	if !envVarNameRegexp.MatchString(value) {
		errs = append(errs, RegexError(envVarNameFmtErrMsg, envVarNameFmt, "my.env-name", "MY_ENV.NAME", "MyEnvName1"))
	}

	errs = append(errs, hasChDirPrefix(value)...)
	return errs
}

env - valueFrom - configMapKeyRef

範例是擷取自 kubernetes 官方網站,撰寫一個 yaml 檔送給 kubernetes 告訴 kubernetes 幫忙啟動一個 pod 並且建立一個 configmap ,將 ConfigMap 中定義的 special.how 數值作為 Pod 中的 SPECIAL_LEVEL_KEY 環境變數。

configmap/multiple key-value pairs.yaml

1
2
3
4
5
6
7
8
apiVersion: v1
kind: ConfigMap
metadata:
  name: special-config
  namespace: default
data:
  SPECIAL_LEVEL: very
  SPECIAL_TYPE: charm

pods/pod-configmap-envFrom.yaml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
apiVersion: v1
kind: Pod
metadata:
  name: dapi-test-pod
spec:
  containers:
    - name: test-container
      image: k8s.gcr.io/busybox
      command: [ "/bin/sh", "-c", "env" ]
      env:
        # Define the environment variable
        - name: SPECIAL_LEVEL_KEY
          valueFrom:
            configMapKeyRef:
              # The ConfigMap containing the value you want to assign to SPECIAL_LEVEL_KEY
              name: special-config
              # Specify the key associated with the value
              key: special.how
  restartPolicy: Never

話不多說我們先來看 kubelet 怎麼從 api server 經過層層關卡抓到 pod yaml 裡面寫的東西,再取得對應的 configmap ,這邊的code 是接續著env - valueFrom - configMapRef 那一小節的 code。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
func (kl *Kubelet) makeEnvironmentVariables(pod *v1.Pod, container *v1.Container, podIP string, podIPs []string) ([]kubecontainer.EnvVar, error) {
	...
	for _, envFrom := range container.EnvFrom
	    switch {
        
		//如果有找到  ConfigMapRef 這個欄位的話
		case envFrom.ConfigMapRef != nil:
	...
	//接續env - valueFrom - configMapRef 小節
    
    
	//遞迴 container 的Env 欄位
	for _, envVar := range container.Env {
		runtimeVal := envVar.Value
		if runtimeVal != "" {
        
			...
			//若是有定義 ValueFrom 欄位的話
		} else if envVar.ValueFrom != nil {
			// Step 1b: resolve alternate env var sources
			//需要判斷是從 from 哪一種資源,本篇文章只關心 configmap 
			switch {
			case envVar.ValueFrom.FieldRef != nil:
				...
                
			case envVar.ValueFrom.ResourceFieldRef != nil:
				...
			//如果 valueFrom 是引用 configmap 的話                
			case envVar.ValueFrom.ConfigMapKeyRef != nil:
				//先取出 ConfigMapKeyRef 為 cm
				cm := envVar.ValueFrom.ConfigMapKeyRef
				//取出 configmap 的 name                
				name := cm.Name
				//取出對應的 key 
				key := cm.Key
				//簡單來說就是有定義 ConfigMapEnvSource.Optional = true,等等會看到要用來做什麼的
				optional := cm.Optional != nil && *cm.Optional
                
                
				// 檢查 configmap map 是否有處理過同樣名字的物件過,如果有就可以直接拿出來用
				configMap, ok := configMaps[name]
            
				//如果 map 找不到東西表示....我們要自己向 api server 要看相關的 configmap 				
				if !ok {
					//這裡用的又是 kubernetes client interface ,沒有這個 interface 什麼都做不了
					//其實...這一段流程也沒拿來幹嘛...xD
					if kl.kubeClient == nil {
						return result, fmt.Errorf("couldn't get configMap %v/%v, no kubeClient defined", pod.Namespace, name)
					}
                    
					//這邊就是用 configmap manager 的 GetConfigMap function 去取得 configmap 
					//所要求的參數也不多只要 namespace 與 configmap 的 name 就能取的對應的資料
					configMap, err = kl.configMapManager.GetConfigMap(pod.Namespace, name)
                    
                    
					//如果在找的過程有出現錯的話,並且有定義 ConfigMapEnvSource.Optional = true 那就不會直接噴 error 
					if err != nil {
						if errors.IsNotFound(err) && optional {
							// ignore error when marked optional
							continue
						}
						return result, err
					}
					//接著以 configmap name 作為 key 以及 configmap 物件的內容作為 value 存在 map 中
					//以供後續重複使用
					configMaps[name] = configMap
				}
				//取出 configmap data 對應的 key 當作環境變數
				runtimeVal, ok = configMap.Data[key]
				//如果拿不到對應的 key 且 ConfigMapEnvSource.Optional = true 那就不會直接噴 error    
				if !ok {
					if optional {
						continue
					}
					return result, fmt.Errorf("couldn't find key %v in ConfigMap %v/%v", key, pod.Namespace, name)
				}
			case envVar.ValueFrom.SecretKeyRef != nil:
				...
			}
		}
        
		//還記得在env - valueFrom - configMapRef 這一小節,有提到過 serviceEnv 嗎?
		//幫大家快速複習一下
		//簡單來說就是如果 pod yaml 有啟用 EnableServiceLinks 的話就需要把同一個 namespaces 所有的 service 
		//對應的名稱、IP 與 port 以環境變數的方式存在 serviceENV 中
		//我們舉一個例子 kubernetes 通常會幫你在 default namespace 的 pod 加上一些環境變數
		//例如:KUBERNETES_PORT_443_TCP_PROTO=tcp
		//這一行環境變數就是 serviceEnv 裡面的資料,資料來源可能透過kubelet 或是 api server 
		//對應到 code 就是在env - valueFrom - configMapRef 這一小節的
		//serviceEnv, err := kl.getServiceEnvVarMap(pod.Namespace, *pod.Spec.EnableServiceLinks)
		
		
		//如果使用者就是想覆蓋這個環境變數該怎麼辦?
		//其實就直接在 yaml 直接設定一下就覆蓋過去了xD也就是下面這一行code而已拉
		delete(serviceEnv, envVar.Name)
		//設定環境變數名稱為 pod spec 中定義的 name value 為從 configmap 或是其他資源取的數值
		tmpEnv[envVar.Name] = runtimeVal
	}

source code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func (kl *Kubelet) makeEnvironmentVariables(pod *v1.Pod, container *v1.Container, podIP string, podIPs []string) ([]kubecontainer.EnvVar, error) 

	...
    
	var result []kubecontainer.EnvVar
    
	serviceEnv, err := kl.getServiceEnvVarMap(pod.Namespace, *pod.Spec.EnableServiceLinks)
	...
	var (
		configMaps = make(map[string]*v1.ConfigMap)
		secrets    = make(map[string]*v1.Secret)
		tmpEnv     = make(map[string]string)
	)
	...
    
	// 將剛剛所產生出的環境變數暫存檔加入到 result 這個 slice ,未來將作為 container 的環境變數
	for k, v := range tmpEnv {
		result = append(result, kubecontainer.EnvVar{Name: k, Value: v})
	}

	// 加入 service env 環境變數
	//簡單來說就是如果 pod yaml 有啟用 EnableServiceLinks 的話就需要把同一個 namespaces 所有的 service 
	//對應的名稱、IP 與 port 以環境變數的方式除存在 serviceENV 中
	for k, v := range serviceEnv {
		//如果 service env 的 key 暫存的環境變數不暫存這個  key 的話
        //加入 service env 的環境變數到 result 的 slice 中
		if _, present := tmpEnv[k]; !present {
			result = append(result, kubecontainer.EnvVar{Name: k, Value: v})
		}
	}
	return result, nil
}

小結

上面我們了解了 kubernetes 的 Manager 怎麼實作並且如何取得對應的 secret 資料了,基本上是依賴透過前兩章內容Kubernetes kubelet cacheBasedManager 現在才知道以及Kubernetes kubelet cacheBasedManager 好喜歡 objectcache 的原因

  1. 透過 cacheBasedManager 註冊 pod 的時候 ,將有使用到 secret 的欄位提取出來建立對應的 reflector ,並且將 reflector 對應到 objectStore 的 objectCacheItem 中。

objectStore 主要用來儲存 reflector 觀測到的物件狀態(新增的情況)

  • 如果有 nginxA 以及 nginxB 兩個 pod 同時都有 xxx-secret 該怎麼辦
    • xxx-secret 對應 xxx-objectCacheItem , xxx-objectCacheItem 要記錄有兩個人 reference 到。
  1. 透過 cacheBasedManager 反註冊 pod 的時候 ,將上一次 pod 引用到 secret 用到的欄位取出來 ,並且將對應的 reflector 到 objectStore 的 objectCacheItem 進行移除。
    objectStore 主要用來儲存 reflector 觀測到的物件狀態(刪除的情況)
  • 如果 nginx B 不再關注這個 xxx-secret 要如何處理?
    • xxx-secret 對應 xxx-objectCacheItem , xxx-objectCacheItem 要記錄現在有只有一個人 reference 到。
  1. GetSecret 我們透過 namespace/name 向 cacheBasedManager 的 objectStore 取得對應的資料
  • objectStore 會拿 namespace/name 作為 key 找到對應到 objectCacheItem
    • objectCacheItem 內有 reflector 可以把資料吐出來

以上為 secret manager 的簡易分析,如果對詳細的步驟有疑慮的話建議先到前兩章節去了解底層是如何處理的!!

文章中若有出現錯誤的見解希望各位在觀看文章的大大們可以指出哪裡有問題,讓我學習改進,謝謝。


Meng Ze Li
WRITTEN BY
Meng Ze Li
Kubernetes / DevOps / Backend

What's on this Page