Kubernetes初始化流程深度拆解:从配置加载到Client创建

问题

在不使用k8s库的情况下,我们该如何访问?

kubernetes组件api-server提供http Rest接口,给其他组件或者外部第三方调用,根据这个思路,我们可以编写如下代码:

func TestToK8SByHttp(t *testing.T) {
    // 解析 kubeconfig
    config, err := parseKubeConfig("/root/.kube/config")
    if err != nil {
        fmt.Println("Error parsing kubeconfig:", err)
        return
    }

    // 解码证书和密钥
    certData := decodePEM(config.Users[0].User.ClientCertificateData)
    keyData := decodePEM(config.Users[0].User.ClientKeyData)
    caCertData := decodePEM(config.Clusters[0].Cluster.CertificateAuthorityData)
    // 加载证书对
    cert, err := tls.X509KeyPair(certData, keyData)
    if err != nil {
        fmt.Println("Error loading client cert:", err)
        return
    }
    // 创建 CA 证书池
    caCertPool := x509.NewCertPool()
    if ok := caCertPool.AppendCertsFromPEM(caCertData); !ok {
        fmt.Println("Failed to append CA certificate")
        return
    }

    // 配置 TLS
    tlsConfig := &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      caCertPool, // 添加 CA 证书池
        MinVersion:   tls.VersionTLS12,
    }

    // 创建 transport
    transport := &http.Transport{
        TLSClientConfig: tlsConfig,
    }

    // 创建 HTTP 客户端
    client := &http.Client{
        Transport: transport,
    }

    // 发送请求以获取 Pod 列表
    resp, err := client.Get(fmt.Sprintf("%s/api/v1/namespaces/default/pods", config.Clusters[0].Cluster.Server))
    if err != nil {
        fmt.Println("Error sending request:", err)
        return
    }
    defer resp.Body.Close()

    // 读取响应
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        fmt.Println("Error reading response:", err)
        return
    }
    fmt.Println(string(body))
}

具体步骤,如下:

  1. 配置文件解析:首先执行配置文件的解析工作,旨在从中提取出所有必要的参数与设置信息。
  2. 安全证书及密钥获取与TLS配置构建:随后,从集群中获取CA(Certificate Authority)证书、客户端私钥以及客户端认证数据,并生成传输层安全协议(TLS)所需的配置。
  3. 初始化HTTPS客户端:利用之前步骤所准备的安全配置来创建一个支持加密通信的HTTPS客户端实例。
  4. 定位并访问Pod资源:明确目标Pod的具体API URL路径后,通过已建立的安全连接发起对该URL的请求操作。
  5. 响应解析与对象转换:最后一步是处理服务器返回的响应数据,将其解码并映射为程序中可直接使用的Pod对象模型,从而完成整个流程。

在典型的业务开发场景中,通常会采取以下步骤:

  1. 定义各类资源的结构体对象。
  2. 设定全局常量,以固定各资源URL或将它们嵌入至资源结构体内。
  3. 封装HTTP请求或直接利用现有的HTTP库,为满足特定业务需求的URL提供封装函数。

虽然这种方法能够适应快速发展的业务需求,但也可能引发若干问题:

  • 随着功能增加,代码体积将逐渐膨胀。
  • 缺乏有效的代码审查流程时,开发者可能会忽视前人所遵循的最佳实践,转而依据个人偏好编写新接口,导致风格不一致。
  • 当Kubernetes(k8s)认证机制更新或引入更严格的权限控制策略时,现有解决方案可能需要大规模调整。

鉴于上述挑战,推荐采用Kubernetes官方提供的SDK、REST客户端或其他专用客户端/Operator来实现对集群资源的监控及访问。这不仅能够简化与K8s资源交互的过程,还便于后续扩展。

接下来,我们将深入探讨Kubernetes是如何通过其独特的设计模式和架构来支持这些功能的,并分析其源码中的优秀实践,以此学习这一云原生技术栈的基础构建方法。

如何快速阅读源码

方法概述:

  1. 利用goanalysis工具对client-go库进行全面的静态插桩处理;
  2. 通过特定的测试函数针对某一资源对象执行操作;
  3. 在执行过程中捕获并保存生成的sqlite.db数据库文件;
  4. 再次运用goanalysis对该数据库文件进行深度解析,以实现运行时堆栈信息及调用链关系的直观展示。

以上步骤旨在通过自动化分析手段提高对client-go内部机制的理解与调试效率。

初步使用

func TestRestClient(t *testing.T) {
 config, err := clientcmd.BuildConfigFromFlags("""/root/.kube/config")
 if err != nil {
  t.Fatalf("Error building kubeconfig: %v", err)
 }
 config.APIPath = "/api"
 config.GroupVersion = &v1.SchemeGroupVersion
 config.NegotiatedSerializer = scheme.Codecs.WithoutConversion()
 // 创建 REST 客户端
 restClient, err := rest.RESTClientFor(config)
 if err != nil {
  t.Fatalf("Error creating REST client: %v", err)
 }
 // 发送请求以获取 Pod 列表
 pods := &v1.PodList{}
 err = restClient.Get().
  Namespace("kube-system").
  Resource("pods").
  Do(context.TODO()).
  Into(pods)
 if err != nil {
  t.Fatalf("Error fetching pods: %v", err)
 }

 for _, pod := range pods.Items {
  fmt.Printf("Pod Name: %s\n", pod.Name)
 }
 functrace.CloseTraceInstance()
}

相较于通过150行代码实现的功能,采用restClient来完成相同任务的方法显得更为简洁和直观。

执行完上述代码后,goanalysis插桩函数将在本地生成一个数据库文件,该文件可以上传至goanalysis分析平台以供进一步分析。

初始化流程

接下来,我们将探讨在启动过程中,restClient会执行哪些具体操作。

从上至下,我们可以对以下内容进行详细的解析:

  • features.init:负责初始化环境变量中的特性开关配置,从而确保这些配置可以在整个应用程序范围内被访问和使用。
  • k8s.io/client-go/kubernetes/scheme.initexec.init.0 的一系列初始化函数,主要目的是完成Kubernetes内部Scheme的注册过程。这里提到的Scheme是指一种内存中的资源注册表。

在Kubernetes中存在着多种类型的资源对象,每种类型都代表了系统内的一种具体实体。为了能够有效地管理(包括但不限于统一注册、存储、查询等)这些多样化的资源类型,就需要有一个中心化的机制来维护它们的信息,而Scheme正是承担了这一职责的关键组件。

  • clientcmd.getDefaultServer():
if server := os.Getenv("KUBERNETES_MASTER"); len(server) > 0 {
  return server
 }
return "http://localhost:8080"

尝试获取Kubernetes主节点(k8s-master)的地址时,若未能定位到相应的kubeconfig文件或未明确设置主节点地址,则系统将抛出错误。此外,在这种情况下,默认尝试连接至**localhost:8080**作为替代方案

  • homedir.HomeDir:该功能的主要逻辑是在Windows操作系统环境下定位当前用户的主目录。
// 在 Windows 上:
// 1. 返回第一个包含 `.kube\config` 文件的 %HOME%、%HOMEDRIVE%%HOMEPATH%、%USERPROFILE%。
// 2. 如果这些位置都不包含 `.kube\config` 文件,则返回第一个存在且可写的 %HOME%、%USERPROFILE%、%HOMEDRIVE%%HOMEPATH%。
// 3. 如果这些位置都不可写,则返回第一个存在的 %HOME%、%USERPROFILE%、%HOMEDRIVE%%HOMEPATH%。
// 4. 如果这些位置都不存在,则返回第一个设置的 %HOME%、%USERPROFILE%、%HOMEDRIVE%%HOMEPATH%。
  • NewConfig: 构建与kubeconfig文件相对应的结构化对象。
func NewConfig() *Config {
 return &Config{
  Preferences: *NewPreferences(),
  Clusters:    make(map[string]*Cluster),
  AuthInfos:   make(map[string]*AuthInfo),
  Contexts:    make(map[string]*Context),
  Extensions:  make(map[string]runtime.Object),
 }
}

执行流程

主要步骤如下:

  1. 依据kubeconfig文件中的信息创建并完善配置文件;
  2. 构建RESTClientFor实例;
  3. 指定RESTClient需访问的具体资源类型;
  4. 通过执行Do方法发送请求;
  5. 利用Into方法将响应数据反序列化至对应的结构体中。

接下来根据执行顺序来进行源码的阅读;

goanalysis中保存了执行函数的所有参数,包括结构体receiver。

BuildConfigFromFlags

在Kubernetes(k8s)的源代码设计中,广泛采用了一种模式:通过传入的参数逐步构建和完善配置对象

这种设计使得后续处理流程能够依据这些配置参数进行动态调整和控制。

if kubeconfigPath == "" && masterUrl == "" {
  klog.Warning("Neither --kubeconfig nor --master was specified.  Using the inClusterConfig.  This might not work.")
  kubeconfig, err := restclient.InClusterConfig()
  if err == nil {
   return kubeconfig, nil
  }
  klog.Warning("error creating inClusterConfig, falling back to default config: ", err)
 }
 return NewNonInteractiveDeferredLoadingClientConfig(
  &ClientConfigLoadingRules{ExplicitPath: kubeconfigPath},
  &ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: masterUrl}}).ClientConfig()

在当前的测试脚本中,如果已定义了kubeconfigPath,则不会采用InClusterConfig()方法。初步分析其逻辑如下:

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")
 if len(host) == 0 || len(port) == 0 {
  return nil, ErrNotInCluster
  
 }
 token, err := os.ReadFile(tokenFile)
 if err != nil {
  return nil, err
 }

 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
}

在Kubernetes(k8s)中创建Pod时,系统默认会将一个名为default的ServiceAccount挂载至该Pod。此过程中,不仅包括了向Pod内部提供身份验证令牌及其证书的位置信息,还自动配置了基础环境变量KUBERNETES_SERVICE_HOSTKUBERNETES_SERVICE_PORT

这些设置使得Pod能够与Kubernetes API服务器进行安全通信。通过访问Pod内部,可以进一步确认上述令牌、证书以及环境变量的存在。

创建ClientConfig

主要逻辑概述如下:

  1. 依据Kubeconfig文件中的数据生成NewConfig对象。此过程的核心函数为k8s.io/client-go/tools/clientcmd.(*ClientConfigLoadingRules).Load
  2. 上述步骤完成后,可通过调用k8s.io/client-go/tools/clientcmd.(*DirectClientConfig).ClientConfig方法来获取最终配置的结果。
  3. 在处理过程中,使用了mergo库以实现两个结构体之间的合并操作。
    这样改写后,语句更加正式且清晰地描述了流程中涉及的技术细节和所使用的工具库。

创建RestConfig

主要逻辑概述:

  1. 从上下文(Context)中提取认证信息(AuthInfo)及集群名称。这些数据应通过分析调用堆栈来获取。
  2. 对提取到的信息进行有效性验证,以确保其准确无误。
  3. 检查请求是否使用了HTTPS协议;若确实如此,则需进一步将认证信息(AuthInfo)与现有配置进行整合。

RESTClientFor

目的:创建Rest客户端

要点概述如下:

  1. 配置传输(Transport)设置;
  2. REST接口采用了基于令牌桶算法的流量控制机制,其默认参数为每秒查询率(QPS)10次,突发请求量上限为5。该实现参考了golang.org/x/time/rate包中的相关功能。

上述流程主要涉及参数的转换、合并以及多种结构体的转换。整个过程中,并未涉及到需要特别掌握的新知识或技术。


请求流程

关键点概述如下:

  1. 请求对象的构建:通过封装顶层 net/http 客户端,创建了自定义的 Request 对象。在此过程中,还会生成一个 **DNSMetricsTrace** 实例,专门用于记录与该请求相关的DNS解析过程。
  2. 执行请求 (Request.Do()) 时的流程包括
    • 预处理阶段:执行一系列前置检查以确保请求的有效性。
    • 等待令牌:在首次尝试发起请求前,会先等待获取到访问令牌,这一步骤旨在控制对API服务器的请求速率。
    • 访问前的操作
      • 检查当前操作是否已被取消。
      • 获取访问令牌,进一步限制向API服务器发送请求的速度,避免过载。
    • 访问后的处理:完成请求后,系统将自动记录相关访问指标(metrics)。
    • 重试逻辑管理:根据具体情况适当地更新重试计数器,并据此决定是否需要发起新的重试尝试。
    • 响应转换:将从服务器接收到的 Resp 对象转换为更加易于使用的 Result 对象。

特别注意:在整个流程中,readAndCloseResponseBody() 函数扮演着至关重要的角色,负责正确地读取并关闭HTTP响应体。

干货分享

net/http实战技巧

在上述代码中提到readAndCloseResponseBody,这里来看一下为什么其至关重要?

func (r *Request) Do(ctx context.Context) Result {
 if r.body == nil {
  logBody(ctx, 2"Request Body", r.bodyBytes)
 }

 var result Result
 err := r.request(ctx, func(req *http.Request, resp *http.Response) {
  result = r.transformResponse(ctx, resp, req)
 })
 if err != nil {
  return Result{err: err}
 }
 if result.err == nil || len(result.body) > 0 {
  metrics.ResponseSize.Observe(ctx, r.verb, r.URL().Host, float64(len(result.body)))
 }
 return result
}

在Do函数中,最终执行请求的函数为request,其内部填充了transformResponse将响应体转换成Result对象;

for {
  if err := retry.Before(ctx, r); err != nil {
   return retry.WrapPreviousError(err)
  }
  req, err := r.newHTTPRequest(ctx)
     ...
  resp, err := client.Do(req)
  ...
  retry.After(ctx, r, resp, err)
        ...
  done := func() bool {
   defer readAndCloseResponseBody(resp)

   // if the server returns an error in err, the response will be nil.
   f := func(req *http.Request, resp *http.Response) {
    if resp == nil {
     return
    }
    fn(req, resp)
   }
            ...
   f(req, resp)
   return true
  }()
  if done {
   return retry.WrapPreviousError(err)
  }
 }

关键步骤概述如下:

  1. 采用net/http标准库中的Client.Do方法直接发起HTTP请求。
  2. 将请求对象(req)与响应对象(resp)传递给名为done的闭包函数。在该闭包内,调用了作为参数传入的transformResponse函数,其实现细节如下所示:
 var body []byte
 if resp.Body != nil {
  data, err := io.ReadAll(resp.Body)
  switch err.(type) {
  case nil:
   body = data
  case http2.StreamError:
   streamErr := fmt.Errorf("stream error when reading response body, may be caused by closed connection. Please retry. Original error: %w", err)
   return Result{
    err: streamErr,
   }
  default:
   unexpectedErr := fmt.Errorf("unexpected error when reading response body. Please retry. Original error: %w", err)
   return Result{
    err: unexpectedErr,
   }
  }
 }
    ....
    return Result{
        body:        body,
        contentType: contentType,
        statusCode:  resp.StatusCode,
        decoder:     decoder,
        warnings:    handleWarnings(resp.Header, r.warningHandler),
    }

在此处可以看到,代码已将 resp.Body 中的内容全部读取完毕。此外,还存在一个名为 readAndCloseResponseBody 的外部函数。下面对为何必须完全读取 resp 中所有内容的原因进行解释:
在HTTP请求过程中,为了减少客户端与服务器之间建立连接所导致的资源消耗,通常会采用持久连接(keep-alive)以实现连接复用。对于Go语言中的 net/http 标准库而言,具体行为如下:

  • 如果在未完成响应体读取的情况下就关闭了该响应体,那么默认的HTTP传输机制可能会选择关闭此连接
  • 相反地,如果是在读取完响应体之后再关闭它,则默认情况下HTTP传输不会立即关闭此连接;因此,这条连接有可能被后续请求重用

transformResponse并已经从中读取了resp.Body,为什么还需要readAndCloseResponseBody?来看一下其源码:

func readAndCloseResponseBody(resp *http.Response) {
 if resp == nil {
  return
 }
    // 大概为2KB
 const maxBodySlurpSize = 2 << 10
 defer resp.Body.Close()

 if resp.ContentLength <= maxBodySlurpSize {
  io.Copy(io.Discard, &io.LimitedReader{R: resp.Body, N: maxBodySlurpSize})
 }
}

当变量resp.Content的长度小于或等于2KB时,程序会继续将内容读取并丢弃至io.Discard,之后再关闭连接。

这里的考虑点如下:

  1. 处理非 2xx 响应或异常场景:当Server 返回非 2xx 状态码(如 4xx/5xx)时,transformResponse 可能仅读取了部分错误信息(例如 Status 对象),但响应体可能仍有未读取的字节。如果不强制读取剩余数据,底层 TCP 连接会因残留数据而无法复用。
  2. 防御性编程:
    1. 分块传输编码(Chunked Encoding):某些响应可能采用分块传输(如日志流或 Watch 接口),即使 transformResponse 已读取数据,底层连接可能仍有未处理的分块标记。io.Copy 会确保分块协议的完整性,避免后续请求因协议错乱而失败。
    2. 超时或取消请求:如果请求在读取过程中被取消或超时,响应体可能未被完全消费。io.Copy 作为兜底逻辑,会强制读取至最大阈值(2KB),防止连接泄漏。
  3. 性能优化:限制读取大小避免资源浪费,设置 2KB 的上限是为了平衡资源消耗与连接复用。对于大型响应体(如 1GB 的日志文件),完全读取会浪费 CPU 和内存;而 2KB 足够覆盖大多数残留场景,同时避免性能损耗。

设计模式-建造者模式

这里我们回顾下restClient使用的非常爽,非常丝滑:

err = restClient.Get().
  Namespace("kube-system").
  Resource("pods").
  Do(context.TODO()).
  Into(pods)

这段代码的链式调用确实体现了建造者模式(Builder Pattern)的精髓。建造者模式的核心目的是将复杂对象的构建与表示分离,通过逐步构造的方式,让代码可读性更高、扩展性更强。

当我们在考虑到复杂对象需要多维度进行构建时,可以使用建造者模式,来YY一下,这里使用的思路,从文章前头我们已经提出过,自己需要去实现访问k8s需要哪些步骤;进一步发散一下,以取东西为例,有以下几个问题:

  1. 命名空间:解决我们去哪个房间中取东西;
  2. 资源类型:我们需要取哪个东西;
  3. HTTP Method的使用:如Post,Get,Put,Delete,以什么方式取东西;
  4. Into:取东西应该放在哪个位置;
  5. VersionedParams:取东西的时候需要限制什么,注意什么?

针对多维度的考量,并为了最大程度地复用代码,采用链式调用来展示这些流程显得更为清晰且易于使用。然而,在建造者模式(Builder Pattern)的经典定义中,存在一个名为Director的角色,该角色负责确定构建步骤的具体顺序和逻辑,从而实现构建过程与具体实现之间的解耦。在当前情况下,并未明确指出哪个部分承担了这一Director的角色。
根据传统意义上的建造者模式,Director对象的作用是指导一系列构建操作如何有序执行(例如,在这里我们如何确定下一个被调用的方法以完成整个请求流程)。但在Kubernetes client-go库中的RestClient设计里,则采用了流畅接口(Fluent Interface)的一种变体形式,这种设计方式使得显式的Director对象不再必要。
若要强行引入Director概念来解释上述机制的话,我们可以这样理解其背后的设计思路:

// 定义 Director
type RequestDirector struct {
    builder RequestBuilder
}

func (d *RequestDirector) BuildPodListRequest(namespace string) {
    d.builder.SetMethod("GET")
    d.builder.SetNamespace(namespace)
    d.builder.SetResource("pods")
}

// 客户端代码
builder := &ConcreteRequestBuilder{}
director := &RequestDirector{builder: builder}
director.BuildPodListRequest("kube-system")
req := builder.Build()
resp, err := req.Do(ctx)

这种设计方案与RestClient的定位不符。作为其他三个客户端的基础支持层,直接套用现有模式显然无法满足实际应用场景的需求。

总结

以上是我对RestClient进行全面测试与深入阅读后所整理的内容。

如果你觉得这些信息对你有所帮助,或者你从中学到了一些新的知识和技巧,请不要吝啬你的支持,可以通过“一键三连”为我加油打气。这不仅是对我工作的认可,也是激励我继续创作高质量内容的动力。

未来,我还将持续深入探索client-go的源码,并分享我的发现与见解。

如果你对Go语言在Kubernetes中的应用感兴趣,特别是想了解更多关于client-go库如何工作的细节,那么请务必关注我的更新。这样你就不会错过任何一篇精彩的文章了。让我们一起学习进步,在技术的道路上不断前行!

番外

:小唐,小唐,这些个分析图怎么弄的?怎么简化源码阅读的Debug过程?

:敬请关注小唐的开源项目 goanalysis。该项目中,我已经完成了RestClient相关功能的实现与调试工作,因此您可以直接访问并使用: http://175.178.49.104:8000/

点击程序运行分析,选择分析文件,即可自己阅读一遍。

posted @ 2025-04-07 18:01  tohearts  阅读(54)  评论(0)    收藏  举报