使用 C# 開發 Kubernetes 組件,獲取集群資源資訊

寫什麼呢

前段時間使用 C# 寫了個項目,使用 Kubernetes API Server,獲取資訊以及監控 Kubernetes 資源,然後結合 Neting 做 API 網關。

體驗地址 //neting.whuanle.cn:30080/

帳號 admin,密碼 admin123

image-20220124202659239

本篇文章主要介紹,如何通過 C# 開發基於 Kuberetes 的應用,實現獲取 Kubernets 中各種資源的資訊,以及實現 Conroller 的前提知識。而在下一篇中則會講解如何實現 Conroller 和 Kubernetes Operator。

Kubernetes API Server

kube-apiserver 是 k8s 主要進程之一,apiserver 組件公開了 Kubernetes API (HTTP API),apiserver 是 Kubernetes 控制面的前端,我們可以用 Go、C# 等程式語言寫程式碼,遠程調用 Kubernetes,控制集群的運行。apiserver 暴露的 endiont 埠是 6443。

為了控制集群的運行,Kubernetes 官方提供了一個名為 kubectl 的二進位命令行工具,正是 apiserver 提供了介面服務,kubectl 解析用戶輸入的指令後,向 apiserver 發起 HTTP 請求,再將結果回饋給用戶。

kubectl

kubectl 是 Kubernetes 自帶的一個非常強大的控制集群的工具,通過命令行操作去管理整個集群。

Kubernetes 有很多可視化面板,例如 Dashboard,其背後也是調用 apiserver 的 API,相當於前端調後端。

總之,我們使用的各種管理集群的工具,其後端都是 apiserver,通過 apiserver,我們還可以訂製各種各樣的管理集群的工具,例如網格管理工具 istio。騰訊雲、阿里雲等雲平台都提供了在線的 kubernetes 服務,還有控制台可視化操作,也是利用了 apiserver。

你可以參考筆者寫的 Kubernetes 電子書,了解更多://k8s.whuanle.cn/1.basic/5.k8s.html

img

簡而言之, Kubernetes API Server 是第三方操作 Kubernetes 的入口。

暴露 Kubernetes API Server

首先查看 kube-system 中運行的 Kubernetes 組件,有個 kube-apiserver-master 正在運行。

root@master:~# kubectl get pods -o wide  -n kube-system
NAME                             READY   STATUS    RESTARTS         AGE   IP          NODE     NOMINATED NODE   READINESS GATES
... ...
kube-apiserver-master            1/1     Running   2 (76d ago)      81d   10.0.0.4    master   <none>           <none>
... ...

雖然這些組件很重要,但是只會有一個實例,並且以 Pod 形式運行,而不是 Deployment,這些組件只能放在 master 節點運行。

然後查看 admin.conf 文件,可以通過 /etc/kubernetes/admin.conf$HOME/.kube/config 路徑查看到。

image-20220124204238519

admin.conf 文件是訪問 Kubernetes API Server 的憑證,通過這個文件,我們可以使用編程訪問 Kubernetes 的 API 介面。

但是 admin.conf 是很重要的文件,如果是開發環境開發集群,那就隨便造,如果是生產環境,請勿使用,可通過角色綁定等方式限制 API 訪問授權。

然後把 admin.conf 或 config 文件下載到本地。

你可以使用 kubectl edit pods kube-apiserver-master -n kube-system 命令,查看 Kubernetes API Server 的一些配置資訊。

由於 Kubernetes API Server 默認是通過集群內訪問的,如果需要遠程訪問,則需要暴露到集群外(與是否都在內網無關,與是否在集群內有關)。

將 API Server 暴露到集群外:

kubectl expose pod  kube-apiserver-master --type=NodePort --port=6443 -n kube-system

查看節點隨機分配的埠:

root@master:~# kubectl get svc -n kube-system
NAME                    TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)                  AGE
kube-apiserver-master   NodePort    10.101.230.138   <none>        6443:32263/TCP           25s

32263 埠是 Kubernetes 自動分配,每個人的都不一樣。

然後通過 IP:32263 即可測試訪問。

image-20220124205410165

如果你的集群安裝了 CoreDNS,那麼通過其他節點的 IP,也可以訪問到這個服務。

然後將下載的 admin.conf 或者 config 文件(請改名為 admin.conf),修改裡面的 server 屬性,因為我們此時是通過遠程訪問的。

連接到 API Server

新建一個 MyKubernetes 控制台項目,然後將 admin.conf 文件複製放到項目中,隨項目生成輸出。

image-20220124211315915

然後在 Nuget 中搜索 KubernetesClient 包,筆者當前使用的是 7.0.1。

然後在項目中設置環境變數:

image-20220124211447537

這個環境變數本身是 ASP.NET Core 自帶的,控制台程式中沒有。

下面寫一個方法,用於實例化和獲取 Kubernetes 客戶端:

    private static Kubernetes GetClient()
    {
        KubernetesClientConfiguration config;
        if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
        {
            // 通過配置文件
            config = KubernetesClientConfiguration.BuildConfigFromConfigFile("./admin.conf");
        }
        else
        {
            // 通過默認的 Service Account 訪問,必須在 kubernetes 中運行時才能使用
            config = KubernetesClientConfiguration.BuildDefaultConfig(); 
        }
        return new Kubernetes(config);
    }

邏輯很簡單,如果是開發環境,則使用 admin.conf 文件訪問,如果是非開發環境,則 BuildDefaultConfig() 自動獲取訪問憑證,此方式只在 Pod 中運行時有效,利用 Service Account 認證。

下面測試一下,獲取全部命名空間:

    static async Task Main()
    {
        var client = GetClient();
        var namespaces  = await client.ListNamespaceAsync();
        foreach (var item in namespaces.Items)
        {
            Console.WriteLine(item.Metadata.Name);
        }
    }

image-20220124211839667

好了!你已經會獲取 Kubernetes 資源了,打開入門的第一步!秀兒!

客戶端小知識

雖然打開了入門的第一步,但是不要急著使用各種 API ,這裡我們來了解一下 Kubernetes 各種資源在客戶端中的定義,和如何解析結構。

首先,在 Kubernetes Client C# 的程式碼中,所有 Kubernetes 資源的模型類,都在 k8s.Models 中記錄。

如果我們要在 Kubernetes 中,查看一個對象的定義,如 kube-systtem 命名空間的:

kubectl get namespace kube-system -o yaml
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: "2021-11-03T13:57:10Z"
  labels:
    kubernetes.io/metadata.name: kube-system
  name: kube-system
  resourceVersion: "33"
  uid: f0c1f00d-2ee4-40fb-b772-665ac2a282d7
spec:
  finalizers:
  - kubernetes
status:
  phase: Active

C# 中,模型的結構與其一模一樣:

image-20220124212842265

在客戶端中,模型的名稱以 apiVersion 版本做前綴,並且通過 V1NamespaceList 獲取這類對象的列表。

如果要獲取某類資源,其介面都是以 List 開頭的,如 client.ListNamespaceAsync()client.ListAPIServiceAsync()client.ListPodForAllNamespacesAsync() 等。

看來,學習已經步入正軌了,讓我們來實驗練習吧!

img

如何解析一個 Service

這裡筆者貼心給讀者準備了一些練習,第一個練習是解析一個 Service 的資訊出來。

查看前面創建的 Servicie:

kubectl get svc  kube-apiserver-master -n kube-system -o yaml

對應結構如下:

apiVersion: v1
kind: Service
metadata:
  creationTimestamp: "2022-01-24T12:51:32Z"
  labels:
    component: kube-apiserver
    tier: control-plane
  name: kube-apiserver-master
  namespace: kube-system
  resourceVersion: "24215604"
  uid: ede0e3df-8ef6-45c6-9a8d-2a2048c6cb12
spec:
  clusterIP: 10.101.230.138
  clusterIPs:
  - 10.101.230.138
  externalTrafficPolicy: Cluster
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  ipFamilyPolicy: SingleStack
  ports:
  - nodePort: 32263
    port: 6443
    protocol: TCP
    targetPort: 6443
  selector:
    component: kube-apiserver
    tier: control-plane
  sessionAffinity: None
  type: NodePort
status:
  loadBalancer: {}

我們在 C# 中定義一個這樣的模型類:

    public class ServiceInfo
    {
        /// <summary>
        /// SVC 名稱
        /// </summary>
        public string Name { get; set; } = null!;

        /// <summary>
        /// 三種類型之一 <see cref="ServiceType"/>
        /// </summary>
        public string? ServiceType { get; set; }
        /// <summary>
        /// 命名空間
        /// </summary>
        public string Namespace { get; set; } = null!;

        /// <summary>
        /// 有些 Service 沒有此選項
        /// </summary>
        public string ClusterIP { get; set; } = null!;

        /// <summary>
        /// 外網訪問 IP
        /// </summary>
        public string[]? ExternalAddress { get; set; }

        public IDictionary<string, string>? Labels { get; set; }

        public IDictionary<string, string>? Selector { get; set; }

        /// <summary>
        /// name,port
        /// </summary>
        public List<string>? Ports { get; set; }

        public string[]? Endpoints { get; set; }

        public DateTime? CreationTime { get; set; }

        // 關聯的 Pod 以及 pod 的 ip
    }

下面,指定獲取哪個命名空間的 Service 及其關聯的 Endpoint 資訊。

    static async Task Main()
    {
        var result = await GetServiceAsync("kube-apiserver-master","kube-system");
        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(result));
    }
    public static async Task<ServiceInfo> GetServiceAsync(string svcName, string namespaceName)
    {
        var client = GetClient();
        var service = await client.ReadNamespacedServiceAsync(svcName, namespaceName);

        // 獲取 service 本身的資訊
        ServiceInfo info = new ServiceInfo
        {
            Name = service.Metadata.Name,
            Namespace = service.Metadata.NamespaceProperty,
            ServiceType = service.Spec.Type,
            Labels = service.Metadata.Labels,
            ClusterIP = service.Spec.ClusterIP,
            CreationTime = service.Metadata.CreationTimestamp,
            Selector = service.Spec.Selector.ToDictionary(x => x.Key, x => x.Value),
            ExternalAddress = service.Spec.ExternalIPs?.ToArray(),
        };

        // service -> endpoint 的資訊
        var endpoint = await client.ReadNamespacedEndpointsAsync(svcName, namespaceName);
        List<string> address = new List<string>();
        foreach (var sub in endpoint.Subsets)
        {
            foreach (var addr in sub.Addresses)
            {
                foreach (var port in sub.Ports)
                {
                    address.Add($"{addr.Ip}:{port.Port}/{port.Protocol}");
                }
            }

        }
        info.Endpoints = address.ToArray();
        return info;
    }

輸出結果如下:

image-20220124214525861

親,如果你對 Kubernetes 的網路知識不太清楚,請先打開 //k8s.whuanle.cn/4.network/1.network.html 了解一下呢。

實踐 2

我們知道,一個 Service 可以關聯多個 Pod,為多個 Pod 提供負載均衡等功能。同時 Service 有 externalIP、clusterIP 等屬性,要真正解析出一個 Service 是比較困難的。例如 Service 可以只有埠,沒有 IP;也可以只使用 DNS 域名訪問;也可以不綁定任何 Pod,可以從 Service A DNS -> Service B IP 間接訪問 B;

Service 包含的情況比較多,讀者可以參考下面這個圖,下面我們通過程式碼,獲取一個 Service 的 IP 和埠資訊,然後生成對應的 IP+埠結構。

image-20220124214857896

單純獲取 IP 和 埠是沒用的,因為他們是分開的,你獲取到的 IP 可能是 Cluter、Node、LoadBalancer 的,有可能只是 DNS 沒有 IP,那麼你這個埠怎麼訪問呢?這個時候必須根據一定的規則,解析資訊,篩選無效數據,才能得出有用的訪問地址。

首先定義一部分枚舉和模型:


    public enum ServiceType
    {
        ClusterIP,
        NodePort,
        LoadBalancer,

        ExternalName
    }

    /// <summary>
    /// Kubernetes Service 和 IP
    /// </summary>
    public class SvcPort
    {

        // LoadBalancer -> NodePort -> Port -> Target-Port

        /// <summary>
        /// 127.0.0.1:8080/tcp、127.0.0.1:8080/http
        /// </summary>
        public string Address { get; set; } = null!;

        /// <summary>
        /// LoadBalancer、NodePort、Cluster
        /// </summary>
        public string Type { get; set; } = null!;

        public string IP { get; set; } = null!;
        public int Port { get; set; }
    }
    public class SvcIpPort
    {
        public List<SvcPort>? LoadBalancers { get; set; }
        public List<SvcPort>? NodePorts { get; set; }
        public List<SvcPort>? Clusters { get; set; }
        public string? ExternalName { get; set; }
    }

編寫解析程式碼:

    static async Task Main()
    {
        var result = await GetSvcIpsAsync("kube-apiserver-master","kube-system");
        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(result));
    }

    public static async Task<SvcIpPort> GetSvcIpsAsync(string svcName, string namespaceName)
    {
        var client = GetClient();
        var service = await client.ReadNamespacedServiceAsync(svcName, namespaceName);

        SvcIpPort svc = new SvcIpPort();

        // LoadBalancer
        if (service.Spec.Type == nameof(ServiceType.LoadBalancer))
        {
            svc.LoadBalancers = new List<SvcPort>();
            var ips = svc.LoadBalancers;

            // 負載均衡器 IP
            var lbIP = service.Spec.LoadBalancerIP;
            var ports = service.Spec.Ports.Where(x => x.NodePort != null).ToArray();
            foreach (var port in ports)
            {
                ips.Add(new SvcPort
                {
                    Address = $"{lbIP}:{port.NodePort}/{port.Protocol}",
                    IP = lbIP,
                    Port = (int)port.NodePort!,
                    Type = nameof(ServiceType.LoadBalancer)
                });
            }
        }

        if (service.Spec.Type == nameof(ServiceType.LoadBalancer) || service.Spec.Type == nameof(ServiceType.NodePort))
        {
            svc.NodePorts = new List<SvcPort>();
            var ips = svc.NodePorts;

            // 負載均衡器 IP,有些情況可以設置 ClusterIP 為 None;也可以手動設置為 None,只要有公網 IP 就行
            var clusterIP = service.Spec.ClusterIP;
            var ports = service.Spec.Ports.Where(x => x.NodePort != null).ToArray();
            foreach (var port in ports)
            {
                ips.Add(new SvcPort
                {
                    Address = $"{clusterIP}:{port.NodePort}/{port.Protocol}",
                    IP = clusterIP,
                    Port = (int)port.NodePort!,
                    Type = nameof(ServiceType.NodePort)
                });
            }
        }
        
        // 下面這部分程式碼是正常的,使用 {} 可以隔離部分程式碼,避免變數重名
        // if (service.Spec.Type == nameof(ServiceType.ClusterIP))
        // 如果 Service 沒有 Cluster IP,可能使用了無頭模式,也有可能不想出現 ClusterIP
        //if(service.Spec.ClusterIP == "None")
        {
            svc.Clusters = new List<SvcPort>();
            var ips = svc.Clusters;
            var clusterIP = service.Spec.ClusterIP;

            var ports = service.Spec.Ports.ToArray();
            foreach (var port in ports)
            {
                ips.Add(new SvcPort
                {
                    Address = $"{clusterIP}:{port.Port}/{port.Protocol}",
                    IP = clusterIP,
                    Port = port.Port,
                    Type = nameof(ServiceType.ClusterIP)
                });
            }
        }

        if (!string.IsNullOrEmpty(service.Spec.ExternalName))
        {
            /* NAME            TYPE           CLUSTER-IP       EXTERNAL-IP          PORT(S)     AGE
               myapp-svcname   ExternalName   <none>           myapp.baidu.com      <none>      1m
               myapp-svcname ->  myapp-svc 
               訪問 myapp-svc.default.svc.cluster.local,變成 myapp.baidu.com
             */
            svc.ExternalName = service.Spec.ExternalName;
        }
        return svc;
    }

規則解析比較複雜,這裡就不詳細講解,讀者如有疑問,可聯繫筆者討論。

主要規則:LoadBalancer -> NodePort -> Port -> Target-Port

最終結果如下:

image-20220124220414221

通過這部分程式碼,可以解析出 Service 在 External Name、LoadBalancer、NodePort、ClusterIP 等情況下可真正訪問的地址列表。

實踐3 Endpoint 列表

如果對 Endpoint 不太了解,請打開 //k8s.whuanle.cn/4.network/2.endpoint.html 看一下相關知識。

image-20220124220757582

在 Kubernetes 中,Service 不是直接關聯 Pod 的,而是通過 Endpoint 間接代理 Pod。當然除了 Service -> Pod,通過 Endpoint,也可以實現接入集群外的第三方服務。例如資料庫集群不在 Kubernetes 集群中,但是想通過 Kubernetes Service 統一訪問,則可以利用 Endpoint 進行解耦。這裡不多說,讀者可以參考 //k8s.whuanle.cn/4.network/2.endpoint.html

這裡這小節中,筆者也將會講解如何在 Kubernetes 中分頁獲取資源。

首先定義以下模型:


    public class SvcInfoList
    {
        /// <summary>
        /// 分頁屬性,具有臨時有效期,具體由 Kubernetes 確定
        /// </summary>
        public string? ContinueProperty { get; set; }

        /// <summary>
        /// 預計剩餘數量
        /// </summary>
        public int RemainingItemCount { get; set; }

        /// <summary>
        /// SVC 列表
        /// </summary>
        public List<SvcInfo> Items { get; set; } = new List<SvcInfo>();
    }

    public class SvcInfo
    {
        /// <summary>
        /// SVC 名稱
        /// </summary>
        public string Name { get; set; } = null!;

        /// <summary>
        /// 三種類型之一 <see cref="ServiceType"/>
        /// </summary>
        public string? ServiceType { get; set; }

        /// <summary>
        /// 有些 Service 沒有 IP,值為 None
        /// </summary>
        public string ClusterIP { get; set; } = null!;

        public DateTime? CreationTime { get; set; }

        public IDictionary<string, string>? Labels { get; set; }

        public IDictionary<string, string>? Selector { get; set; }

        /// <summary>
        /// name,port
        /// </summary>
        public List<string> Ports { get; set; }

        public string[]? Endpoints { get; set; }
    }

Kubernetes 中的分頁,沒有 PageNo、PageSize、Skip、Take 、Limit 這些,並且分頁可能只是預計,不一定完全準確。

第一次訪問獲取對象列表時,不能使用 ContinueProperty 屬性。

第一次訪問 Kubernets 後,獲取 10 條數據,那麼 Kubernetes 會返回一個 ContinueProperty 令牌,和剩餘數量 RemainingItemCount。

那麼我們可以通過 RemainingItemCount 計算大概的分頁數字。因為 Kubernetes 是不能直接分頁的,而是通過類似游標的東西,記錄當前訪問的位置,然後繼續向下獲取對象。ContinueProperty 保存了當前查詢游標的令牌,但是這個令牌有效期是幾分鐘。

解析方法:

    public static async Task<SvcInfoList> GetServicesAsync(string namespaceName, 
                                                           int pageSize = 1, 
                                                           string? continueProperty = null)
    {
        var client = GetClient();

        V1ServiceList services;
        if (string.IsNullOrEmpty(continueProperty))
        {
            services = await client.ListNamespacedServiceAsync(namespaceName, limit: pageSize);
        }
        else
        {
            try
            {
                services = await client.ListNamespacedServiceAsync(namespaceName, 
                                                                   continueParameter: continueProperty, 
                                                                   limit: pageSize);
            }
            catch (Microsoft.Rest.HttpOperationException ex)
            {
                throw ex;
            }
            catch
            {
                throw;
            }
        }

        SvcInfoList svcList = new SvcInfoList
        {
            ContinueProperty = services.Metadata.ContinueProperty,
            RemainingItemCount = (int)services.Metadata.RemainingItemCount.GetValueOrDefault(),
            Items = new List<SvcInfo>()
        };

        List<SvcInfo> svcInfos = svcList.Items;
        foreach (var item in services.Items)
        {
            SvcInfo service = new SvcInfo
            {
                Name = item.Metadata.Name,
                ServiceType = item.Spec.Type,
                ClusterIP = item.Spec.ClusterIP,
                Labels = item.Metadata.Labels,
                Selector = item.Spec.Selector,
                CreationTime = item.Metadata.CreationTimestamp
            };
            // 處理埠
            if (item.Spec.Type == nameof(ServiceType.LoadBalancer) || item.Spec.Type == nameof(ServiceType.NodePort))
            {
                service.Ports = new List<string>();
                foreach (var port in item.Spec.Ports)
                {
                    service.Ports.Add($"{port.Port}:{port.NodePort}/{port.Protocol}");
                }
            }
            else if (item.Spec.Type == nameof(ServiceType.ClusterIP))
            {
                service.Ports = new List<string>();
                foreach (var port in item.Spec.Ports)
                {
                    service.Ports.Add($"{port.Port}/{port.Protocol}");
                }
            }

            var endpoint = await client.ReadNamespacedEndpointsAsync(item.Metadata.Name, namespaceName);
            if (endpoint != null && endpoint.Subsets.Count != 0)
            {
                List<string> address = new List<string>();
                foreach (var sub in endpoint.Subsets)
                {
                    if (sub.Addresses == null) continue;
                    foreach (var addr in sub.Addresses)
                    {
                        foreach (var port in sub.Ports)
                        {
                            address.Add($"{addr.Ip}:{port.Port}/{port.Protocol}");
                        }
                    }

                }
                service.Endpoints = address.ToArray();
            }
            svcInfos.Add(service);
        }

        return svcList;
    }

規則解析比較複雜,這裡就不詳細講解,讀者如有疑問,可聯繫筆者討論。

調用方法:

    static async Task Main()
    {
        var result = await GetServicesAsync("default", 2);
        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(result.Items));

        if (result.RemainingItemCount != 0)
        {
            while (result.RemainingItemCount != 0)
            {
                Console.WriteLine($"剩餘 {result.RemainingItemCount} 條數據,{result.RemainingItemCount / 3 + (result.RemainingItemCount % 3 == 0 ? 0 : 1)} 頁,按下回車鍵繼續獲取!");
                Console.ReadKey();
                result = await GetServicesAsync("default", 2, result.ContinueProperty); 
                Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(result.Items));
            }
        }
    }

image-20220124223153274

image-20220124223217616

上面的實踐中,程式碼較多,建議讀者啟動後進行調試,一步步調試下來,慢慢檢查數據,對比 Kubernetes 中的各種對象,逐漸加深理解。