k8s client-go快速入门教程及源代码阅读之配置加载

文章目录

从上一篇文章,我们知道使用client-go操作k8s的资源基本分为三步,1. 配置加载,2. 创建客户端,3. 使用接口,这一篇文章主要专注于使用client-go时的配置加载, 而配置加载,主要是加载认证和连接信息。

k8s支持多种认证方式,包括但不限于X509客户端证书,服务账户令牌(serviceaccount token),静态令牌文件等。比较常见的是前两者,X509客户端证书认证一般以kubeconfig的方式存在,而服务账户令牌(serviceaccount token)一般存在于pod内部的/var/run/secrets/kubernetes.io/serviceaccount/token

X509在一定意义上可以简单的理解成SSL/TLS证书

kubeconfig

我们最常操作k8s集群的方式就是使用kubectl, 而kubectl默认会在~/.kube/config位置找kubeconfig, 只有找到kubeconfig才能操作集群。

当然了, 也可以通过--kubeconfig方式手动指定,或者设置环境变量export KUBECONFIG=“指定的位置”

一个简单的kubeconfig示例如下:

apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: "base64编码的X509 CA证书"
    server: "k8s集群访问地址"
  name: youerning.top
contexts:
- context:
    cluster: youerning.top
    user: kubernetes-admin
  name: [email protected]
current-context: [email protected]
kind: Config
preferences: {}
users:
- name: kubernetes-admin
  user:
    client-certificate-data: "base64编码的X509客户端证书"
    client-key-data: "base64编码的X509客户端私钥"

代码加载过程

一般通过以下代码读取kubeconfig

package main

import (
	"flag"
	"fmt"
	"path/filepath"

	"k8s.io/client-go/tools/clientcmd"
	"k8s.io/client-go/util/homedir"
)

func main() {
	var kubeconfig *string
	if home := homedir.HomeDir(); home != "" {
		kubeconfig = flag.String("kubeconfig", filepath.Join(home, ".kube", "config"), "(optional) absolute path to the kubeconfig file")
	} else {
		kubeconfig = flag.String("kubeconfig", "", "absolute path to the kubeconfig file")
	}
	flag.Parse()

	config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)
	if err != nil {
		panic(err)
	}
    // 后续可以基于这个config创建客户端
}

整个调用链是: BuildConfigFromFlags

​ -> NewNonInteractiveDeferredLoadingClientConfig.ClientConfig

​ -> DeferredLoadingClientConfig.createClientConfig

​ -> DirectClientConfig.ClientConfig

代码缩减如下:

config, err := clientcmd.BuildConfigFromFlags("", *kubeconfig)

func BuildConfigFromFlags(masterUrl, kubeconfigPath string) (*restclient.Config, error) {
    // 1.
	return NewNonInteractiveDeferredLoadingClientConfig(
		&ClientConfigLoadingRules{ExplicitPath: kubeconfigPath},
		&ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: masterUrl}}).ClientConfig()
}

func (config *DeferredLoadingClientConfig) ClientConfig() (*restclient.Config, error) {
    // 2. 
	mergedClientConfig, err := config.createClientConfig()
    // 3. 
	mergedConfig, err := mergedClientConfig.ClientConfig()
	switch {
	case err != nil:
	case mergedConfig != nil:
		if !config.loader.IsDefaultConfig(mergedConfig) {
			return mergedConfig, nil
        }
	return mergedConfig, err
}

而上面标注的各个代码步骤是

  1. 通过NewNonInteractiveDeferredLoadingClientConfig封装, 一个延迟加载配置的对象
  2. 读取kubeconfig并转换成clientcmd.ClientConfig
  3. 基于上一步生成最上层需要的restclient.Config对象

其中2,3可以继续深入代码。

mergedClientConfig, err := config.createClientConfig()

func (config *DeferredLoadingClientConfig) createClientConfig() (ClientConfig, error) {
    // 1. 
	mergedConfig, err := config.loader.Load()
	// 2. 
	config.clientConfig = NewNonInteractiveClientConfig(*mergedConfig, currentContext, config.overrides, config.loader)
    
	return config.clientConfig, nil
}

func (rules *ClientConfigLoadingRules) Load() (*clientcmdapi.Config, error) {
	errlist := []error{}
	missingList := []string{}
	kubeConfigFiles := []string{}

	kubeConfigFiles = append(kubeConfigFiles, rules.ExplicitPath)
	kubeconfigs := []*clientcmdapi.Config{}
	for _, filename := range kubeConfigFiles {
        // 3. 
		config, err := LoadFromFile(filename)
		kubeconfigs = append(kubeconfigs, config)
	}

	// 4.
	mapConfig := clientcmdapi.NewConfig()
	for _, kubeconfig := range kubeconfigs {
		mergo.MergeWithOverwrite(mapConfig, kubeconfig)
	}
	// 5.
	nonMapConfig := clientcmdapi.NewConfig()
	for i := len(kubeconfigs) - 1; i >= 0; i-- {
		kubeconfig := kubeconfigs[i]
		mergo.MergeWithOverwrite(nonMapConfig, kubeconfig)
	}
	// 6.
	config := clientcmdapi.NewConfig()
	mergo.MergeWithOverwrite(config, mapConfig)
	mergo.MergeWithOverwrite(config, nonMapConfig)
	return config, utilerrors.NewAggregate(errlist)
}

代码说明如下:

  1. 配置加载入口
  2. 将上一步返回的clientcmdapi.Config封装一下。
  3. 读取配置文件,先解码,再转换成clientcmdapi.Config
  4. 创建一个空的config用于作为目标结果将配置文件合并进来,主要用于多个配置的情况
  5. k8s默认后面的配置文件覆盖前面的配置文件,所以倒序再创建一个按优先级覆盖的的config
  6. 先合并不含有优先级的配置文件到空config对象,再合并含优先级的配置文件到空config对象。

其实我不太懂为啥要合并两次…感觉只使用第二次就可以了.

上面的第三步其实还可以继续深入,但是会设计到编解码和转换的逻辑,这部分是k8s一个核心的逻辑,以后单独将,总的来说LoadFromFile首先将配置文件解码成了clientcmd.api.v1.Config对象, 继而将其转换成了clientcmd.api.Config, 两个对象最大的不同在于切片对象转换成了map对象, 如[]NamedCluster转成map[string]*api.Cluster

转换规则可参考: tools/clientcmd/api/v1目录下的autoConvert_v1_Config_To_api_Config方法。

在加载配置文件之后,就可以基于clientcmdapi.Config对象构造restclient.Config对象了, 两者的主要不同在于前者只是包含了必要的信息,但是不够扁平化,比如前者可以配置多个集群,多个账户,但是后者只要一个就行,再就是,后者可以设置超时,代理,限流等配置,所以就需要将前者的凭证给剥离出来,然后再设置必要的默认参数,比如限流,超时等参数。

mergedConfig, err := mergedClientConfig.ClientConfig()

func (config *DirectClientConfig) ClientConfig() (*restclient.Config, error) {
    // 1. 
	configAuthInfo, err := config.getAuthInfo()
	_, err = config.getContext()
	configClusterInfo, err := config.getCluster()

	if err := config.ConfirmUsable(); err != nil {
		return nil, err
	}

    // 2.
	clientConfig := &restclient.Config{}
	clientConfig.Host = configClusterInfo.Server
	if configClusterInfo.ProxyURL != "" {
		u, err := parseProxyURL(configClusterInfo.ProxyURL)
		clientConfig.Proxy = http.ProxyURL(u)
	}
	if config.overrides != nil && len(config.overrides.Timeout) > 0 {
		timeout, err := ParseTimeout(config.overrides.Timeout)
		clientConfig.Timeout = timeout
	}
	
    // 3.
	if u, err := url.ParseRequestURI(clientConfig.Host); err == nil && u.Opaque == "" && len(u.Path) > 1 {
		u.RawQuery = ""
		u.Fragment = ""
		clientConfig.Host = u.String()
	}

    // 4.
	if restclient.IsConfigTransportTLS(*clientConfig) {
		var err error
		var persister restclient.AuthProviderConfigPersister
		userAuthPartialConfig, err := config.getUserIdentificationPartialConfig(configAuthInfo, config.fallbackReader, persister, configClusterInfo)
		mergo.MergeWithOverwrite(clientConfig, userAuthPartialConfig)

		serverAuthPartialConfig, err := getServerIdentificationPartialConfig(configAuthInfo, configClusterInfo)
		mergo.MergeWithOverwrite(clientConfig, serverAuthPartialConfig)
	}

	return clientConfig, nil
}

上面的代码其实方法名已经比较明显了,大致分为四步

  1. 获取集群,认证信息,以及验证配置文件是否可用
  2. 创建一个空的restclient.Config对象,用于存储配置信息,如超时,代理
  3. 配置Host,也就是要访问的目标主机
  4. 配置集群,认证信息(kubeconfig一般用X509验证,可以简单的理解成TLS双向认证)

小结

k8s在加载配置文件的时候可以指定多个配置文件, 所以它的会有合并配置的操作,解码的时候首先解码成clientcmd.api.v1.Config对象,再转成clientcmd.api.Config(v1转__internal版本), 最后基于此对象生成restclient.Config, 这时配置文件就有足够的信息可以连接集群了,也就能基于此创建客户端了。

可以通过环境变量KUBECONFIG指定额外的配置文件, 多个配置文件以 ; 分隔

服务账户令牌(Serviceaccount token)

单纯靠一个令牌是远远不够的,因为令牌里面并不包含连接信息,而连接信息在环境变量里面,例如

env|grep -i kube
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT=tcp://{服务地址}:443
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_HOST={服务地址}
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT_443_TCP_ADDR={服务地址}
KUBERNETES_PORT_443_TCP=tcp://{服务地址}:443

一般来说这里的服务地址是一个内部地址(clusterIP),由k8s中的service(一个叫做kubernetes的service)负责流量入口。

可以通过下面的命令验证

kubectl get service kubernetes
NAME               TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE
kubernetes         ClusterIP   {服务地址}        <none>        443/TCP             900d

其实/var/run/secrets/kubernetes.io/serviceaccount目录下除了token文件还有ca.crt, namespace两个文件,前者是ca证书用于证书验证,后者可以知道自己在哪个namespace。

手动连接

其实有了这些信息可以手动的访问k8s集群。

当然了,除非手动给pod指定了一个比较大权限serviceaccount,否则,一般来说权限不会太大.

# 设置APIServer地址, 默认就叫这个
APISERVER=https://kubernetes.default.svc

# token所在目录
SERVICEACCOUNT=/var/run/secrets/kubernetes.io/serviceaccount

# 读取namespace
NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace)

# 读取token
TOKEN=$(cat ${SERVICEACCOUNT}/token)

# 配置CA路径, 不指定CA可以通过curl -k参数忽略证书验证
CACERT=${SERVICEACCOUNT}/ca.crt

# 访问/api
curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -X GET ${APISERVER}/api

上面内容来自https://kubernetes.io/docs/tasks/run-application/access-api-from-pod/简单的翻译。

输出如下:

{
  "kind": "APIVersions",
  "versions": [
    "v1"
  ],
  "serverAddressByClientCIDRs": [
    {
      "clientCIDR": "0.0.0.0/0",
      "serverAddress": "{服务地址}:6443"
    }
  ]
}

代码加载过程

一般通过以下代码在集群内部生成restclient.Config

package main

import (
	"fmt"

	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
)

func main() {
	config, err := rest.InClusterConfig()
	if err != nil {
		panic(err.Error())
	}
}

相对于前面的kubeconfig加载,集群内部的配置文件加载简直不要太简单,就一个函数。

func InClusterConfig() (*Config, error) {
	const (
		tokenFile  = "/var/run/secrets/kubernetes.io/serviceaccount/token"
		rootCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
	)
	host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
    
	token, err := ioutil.ReadFile(tokenFile)
	tlsClientConfig := TLSClientConfig{}

	if _, err := certutil.NewPool(rootCAFile); err != nil {
		klog.Errorf("Expected to load root CA config from %s, but got err: %v", rootCAFile, err)
	} else {
		tlsClientConfig.CAFile = rootCAFile
	}

	return &Config{
		// TODO: switch to using cluster DNS.
		Host:            "https://" + net.JoinHostPort(host, port),
		TLSClientConfig: tlsClientConfig,
		BearerToken:     string(token),
		BearerTokenFile: tokenFile,
	}, nil
}

代码非常简单,读取环境变量找到连接的地址和端口,读取token用于配置凭证,加载CA根证书用于TLS认证,最后将这些信息填入到Config对象并返回即可。

总结

要想连接集群必须得获取连接信息和认证信息,这部分常用的有两种方式,1.读取kubeconfig, 2.集群内部使用serviceaccount token, 前者权限比较灵活,后者一般受限比较大。当然了,你也可以在集群内部使用kubeconfig。当我们得到了restclient.Config对象就可以创建各种客户端了,静态客户端,动态客户端。

话说大型项目好喜欢封装呀,我想这是使用设计模式的习惯吧,即尽可能不改变原有数据接口的情况下改变行为,又因为k8s是很多人花了很多时间开发的,所以很难做到一次性完美而没有冗余的设计,因此很多数据结构在不断的封装。

参考链接: https://kubernetes.io/docs/tasks/run-application/access-api-from-pod/

参考链接