Luga Lee
作者Luga Lee·2023-03-09 10:30
系统架构师·None

一文搞懂 K3D

字数 21900阅读 1083评论 0赞 2

Hello folks,作为一款由 Google 开发的开源平台, Kubernetes 主要 用于自动部署、资源扩展、管理以及编排容器化应用程序。其不仅是 提供了一个简单的系统,用于管理跨多个服务器的容器,同时,具备出色的负载平衡和资源分配能力,以确保每个应用程序能够以最佳性能运行。

尽管 Kubernetes 是为在云中运行而构建的,然而,在实际的业务场景中,开发人员出于各种原因需要在其本地计算机上部署及运行它。毕竟,在本地运行往往是一种使用容器编排平台的最为简单模式。基于本地开发环境,能够尽可能以减轻与生产环境的差异,并确保应用程序在生产中有效运行。

但是,在本地设置 Kubernetes 往往需要一个工具来帮助我们在本地计算机上创建环境。 有许多 Kubernetes 开发环境可以帮助开发和测试为 Kubernetes 创建的应用程序,但它们中的每一个都存在一些问题。以笔者的浅薄经验总结:一个良好的开发环境,往往具备以下相关特性:

1、快速启动

2、资源轻量级

3、重启时保持状态

4、易于重置

5、支持跨平台

在本篇文章中, 我们将讨论 K3d 以及它如何让开发人员轻松配置 Kubernetes 开发环境。

K3d,顾名思义,就其名称本身而言,可以表达为 “K3s-in-docker”,其是 K3s 的一个包装器——在 Docker 中运行它的轻量级 Kubernetes。K3d 能够以快速地创建及操作集群而闻名, 得到了众多开发者、组织及相关社区的高度认可, 广泛用于本地 Kubernetes 集群规模项目活动开发。

通过前面的简要解析,我们知道,K3d 是一个旨在轻松在 Docker 中运行 K3s 的实用程序,基于其所提供的一个简单的 CLI 来创建、运行、删除具有 1 到 N 个节点的完全合规的 Kubernetes 集群,是本地容器编排不可或缺的一款平台。那么, K3d 都具备哪些功能呢?

如官网所述,K3s 附带了较多的内置功能和服务,由于 K3s 在容器中运行,其中一些可能只能在 K3d 中以“非正常”方式使用。故此,K3d 囊括了 K3s 所具备的相关功能组件,具体如下所示:

CoreDNS

关于集群 DNS 服务, K3s 相关资源配置列表信息,如下文件所示:

 apiVersion: v1
 kind: ServiceAccount
 metadata:  
      name: coredns  
      namespace: kube-system
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
 metadata: 
      labels:    
         kubernetes.io/bootstrapping: rbac-defaults 
      name: system:coredns
 rules
 :- apiGroups: 
     - ""  
     resources:  
     - endpoints  
     - services 
     - pods 
     - namespaces  verbs:  
     - list  
     - watch
 - apiGroups:
   - discovery.k8s.io  
   resources: 
   - endpointslices  
   verbs:  
   - list  
   - watch
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
 metadata:  
      annotations:   
           rbac.authorization.kubernetes.io/autoupdate: "true" 
      labels:  
         kubernetes.io/bootstrapping: rbac-defaults 
      name: system:coredns
 roleRef:  
     apiGroup: rbac.authorization.k8s.io 
     kind: ClusterRole  
     name: system:coredns
 subjects:
 - kind: ServiceAccount  
   name: coredns  
   namespace: kube-system
 ---
 apiVersion: v1
 kind: ConfigMap
 metadata: 
    name: coredns  
    namespace: kube-system
 data:  
    Corefile: |   
         .:53 {       
               errors       
               health       
               ready       
               kubernetes %{CLUSTER_DOMAIN}% in-addr.arpa ip6.arpa {          
                   pods insecure         
                   fallthrough in-addr.arpa ip6.arpa        
               }       
               hosts /etc/coredns/NodeHosts {         
                   ttl 60          
                   reload 15s        
                   fallthrough       
               }        
               prometheus :9153       
               forward . /etc/resolv.conf        
               cache 30       
               loop       
               reload       
               loadbalance   
        }   
        import /etc/coredns/custom/*.server
  ---
  apiVersion: apps/v1
  kind: Deployment
  metadata: 
       name: coredns  
       namespace: kube-system  
       labels:  
          k8s-app: kube-dns   
          kubernetes.io/name: "CoreDNS"
 spec: 
     #replicas: 1 
     strategy:   
         type: RollingUpdate   
         rollingUpdate:      
            maxUnavailable: 1 
     selector:    
        matchLabels:     
             k8s-app: kube-dns 
     template:   
        metadata:     
             labels:       
                k8s-app: kube-dns  
        spec:      
            priorityClassName: "system-cluster-critical"      
            serviceAccountName: coredns     
            tolerations:       
               - key: "CriticalAddonsOnly"         
                 operator: "Exists"       
               - key: "node-role.kubernetes.io/control-plane"         
                 operator: "Exists"        
                 effect: "NoSchedule"       
              - key: "node-role.kubernetes.io/master"         
                operator: "Exists"          
                effect: "NoSchedule"    
            nodeSelector:      
               beta.kubernetes.io/os: linux      
            topologySpreadConstraints:       
               - maxSkew: 1        
                  topologyKey: kubernetes.io/hostname         
                  whenUnsatisfiable: DoNotSchedule       
                  labelSelector:           
                     matchLabels:            
                          k8s-app: kube-dns     
            containers:      
            - name: coredns       
              image: %{SYSTEM_DEFAULT_REGISTRY}%rancher/mirrored-coredns-coredns:1.8.6       
              imagePullPolicy: IfNotPresent      
              resources:         
                  limits:            
                     memory: 170Mi         
                  requests:           
                     cpu: 100m           
                     memory: 70Mi       
                  args: [ "-conf", "/etc/coredns/Corefile" ]      
                  volumeMounts:        
                  - name: config-volume         
                    mountPath: /etc/coredns         
                    readOnly: true       
                  - name: custom-config-volume          
                    mountPath: /etc/coredns/custom         
                    readOnly: true       
                  ports:      
                  - containerPort: 53         
                     name: dns        
                     protocol: UDP       
                  - containerPort: 53          
                     name: dns-tcp          
                     protocol: TCP        
                  - containerPort: 9153         
                     name: metrics        
                     protocol: TCP       
                  securityContext:         
                     allowPrivilegeEscalation: false         
                     capabilities:            
                         add:          
                         - NET_BIND_SERVICE           
                         drop:           
                         - all         
                     readOnlyRootFilesystem: true       
                  livenessProbe:         
                     httpGet:           
                        path: /health            
                        port: 8080           
                        scheme: HTTP         
                     initialDelaySeconds: 60         
                     periodSeconds: 10         
                     timeoutSeconds: 1         
                     successThreshold: 1          
                     failureThreshold: 3        
                  readinessProbe:         
                     httpGet:            
                        path: /ready            
                        port: 8181            
                        scheme: HTTP         
                      initialDelaySeconds: 0         
                      periodSeconds: 2         
                      timeoutSeconds: 1          
                      successThreshold: 1        
                      failureThreshold: 3      
             dnsPolicy: Default     
             volumes:        
                   - name: config-volume          
                     configMap:            
                         name: coredns           
                         items:          
                         - key: Corefile             
                           path: Corefile          
                         - key: NodeHosts              
                           path: NodeHosts      
                   - name: custom-config-volume         
                     configMap:            
                         name: coredns-custom           
                         optional: true
 ---
 apiVersion: v1
 kind: Service
 metadata:  
      name: kube-dns  
      namespace: kube-system  
      annotations:    
           prometheus.io/port: "9153"   
           prometheus.io/scrape: "true"  
      labels:   
          k8s-app: kube-dns   
          kubernetes.io/cluster-service: "true"   
          kubernetes.io/name: "CoreDNS"
 spec:  
     selector:    
         k8s-app: kube-dns  
      clusterIP: %{CLUSTER_DNS}% 
      ports:
      - name: dns    
        port: 53   
        protocol: UDP 
      - name: dns-tcp    
        port: 53    
        protocol: TCP  
      - name: metrics    
        port: 9153   
        protocol: TCP

备注:所涉及的模板变量(如 %{CLUSTER_DOMAIN}%),在将文件写入文件系统之前将被 K3s 替换。对于 K3d 而言,CoreDNS 工作方式与在其他集群中的工作方式基本上是相同的。不过需要注意的是, Corefile 中配置的 /etc/resolv.conf 不能正常工作,因为 K3s 节点容器中的 /etc/resolv.conf 文件与本地机器上的不同。

从 K3d v5.x 开始,K3d 将条目注入到 NodeHosts 以使集群中的 Pod 能够解析同一 Docker 中其他容器的名称网络(集群网络)和一个名为 host.k3d.internal 的特殊条目,它解析为网络网关的 IP(可用于例如使用本地解析器解析 DNS 查询)。

Local-Path-Provisione

基于 Kubernetes 动态配置持久本地存储, K3s 相关资源配置列表信息,如下文件所示:

apiVersion: v1
kind: ServiceAccount
metadata:  
   name: local-path-provisioner-service-account  
   namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
     name: local-path-provisioner-role
rules:
- apiGroups: [""] 
 resources: ["nodes", "persistentvolumeclaims", "configmaps"] 
 verbs: ["get", "list", "watch"]
- apiGroups: [""]  
   resources: ["endpoints", "persistentvolumes", "pods"] 
   verbs: ["*"]
- apiGroups: [""] 
   resources: ["events"]  
     verbs: ["create", "patch"]
- apiGroups: ["storage.k8s.io"]  
   resources: ["storageclasses"]  
   verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:  
    name: local-path-provisioner-bind
roleRef:  
     apiGroup: rbac.authorization.k8s.io 
     kind: ClusterRole  
     name: local-path-provisioner-role
subjects:
-   kind: ServiceAccount  
    name: local-path-provisioner-service-account  
    namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:  
     name: local-path-provisioner  
     namespace: kube-system
spec:  
    replicas: 1  
    selector:    
      matchLabels:      
           app: local-path-provisioner  template:    metadata:      labels:        app: local-path-provisioner   
      spec:     
          priorityClassName: "system-node-critical"      
          serviceAccountName: local-path-provisioner-service-account     
          tolerations:          
                - key: "CriticalAddonsOnly"            
                  operator: "Exists"          
                - key: "node-role.kubernetes.io/control-plane"            
                  operator: "Exists"           
                  effect: "NoSchedule"         
                - key: "node-role.kubernetes.io/master"            
                  operator: "Exists"           
                  effect: "NoSchedule"      
          containers:     
          - name: local-path-provisioner      
            image: %{SYSTEM_DEFAULT_REGISTRY}%rancher/local-path-provisioner:v0.0.21        
            imagePullPolicy: IfNotPresent      
            command:      
            - local-path-provisioner       
            - start       
            - --config      
            - /etc/config/config.json       
            volumeMounts:      
            - name: config-volume          
              mountPath: /etc/config/        
            env:       
              - name: POD_NAMESPACE         
                valueFrom:          
                    fieldRef:            
                       fieldPath: metadata.namespace    
          volumes:       
              - name:  config-volume          
                configMap:            
                    name: local-path-config
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:  
     name: local-path  
     annotations:   
         storageclass.kubernetes.io/is-default-class: "true"
provisioner: rancher.io/local-path
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
---
kind: ConfigMap
apiVersion: v1
metadata:  
     name: local-path-config  
     namespace: kube-system
data:  
    config.json: |-   
        {    
            "nodePathMap":[     
            {        
                "node":"DEFAULT_PATH_FOR_NON_LISTED_NODES",       
                "paths":["%{DEFAULT_LOCAL_STORAGE_PATH}%"]      
                }     
                ]    
           }  
      setup: |-    
          #!/bin/sh   
          while getopts "m:s:p:" opt   
          do        
                case $opt in           
                       p)           
                       absolutePath=$OPTARG            
                       ;;            
                       s)            
                       sizeInBytes=$OPTARG           
                       ;;            
                       m)           
                       volMode=$OPTARG          
                       ;;       
                esac    
       done    
       mkdir -m 0777 -p ${absolutePath}   
       chmod 701 ${absolutePath}/.. 
    teardown: |-   
       #!/bin/sh   
       while getopts "m:s:p:" opt    
       do        
             case $opt in            
                     p)           
                     absolutePath=$OPTARG          
                     ;;           
                     s)            
                     sizeInBytes=$OPTARG           
                     ;;           
                     m)          
                     volMode=$OPTARG         
                     ;;       
             esac    
    done  
    rm -rf ${absolutePath} 
helperPod.yaml: |-    
    apiVersion: v1    
    kind: Pod    
    metadata:      
         name: helper-pod    
    spec:     
         containers:     
         - name: helper-pod      
         image: %{SYSTEM_DEFAULT_REGISTRY}%rancher/mirrored-library-busybox:1.34.1

在 K3d 中,Local-Path-Provisioner 使用的是 位于容器的文件系统中的 本地路径(默认为 /var/lib/rancher/k3s/storage),这意味着默认情况下它不会映射到某个地方,例如,在我们的用户主目录中供使用。

在实际的业务场景中,我们可能需要将一些本地目录映射到该路径以轻松使用此路径中的文件,此时,可借助以下命令行参数:

--volume $HOME/some/directory:/var/lib/rancher/k3s/storage@all 添加到我们的 K3d 集群以进行相关操作命令的创建。

Traefik

在 K3 s 中,Kubernetes Ingress Controller 即入口控制器默认使用的是 Traefik 接入层代理,其版本为 1.x。K3d 在容器中运行 K3s,因此我们需要在主机上暴露 Http/Https 端口才能轻松访问集群中的 Ingress 资源。其 相关资源配置列表信息,如下文件所示:

---
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata: 
     name: traefik-crd 
     namespace: kube-system
spec:  
    chart: https://%{KUBERNETES_API}%/static/charts/traefik-crd-10.14.100.tgz
---
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:  
     name: traefik  
     namespace: kube-system
spec:  
    chart: https://%{KUBERNETES_API}%/static/charts/traefik-10.14.100.tgz  
    set:    
        global.systemDefaultRegistry: "%{SYSTEM_DEFAULT_REGISTRY_RAW}%"  
    valuesContent: |-   
        rbac:     
            enabled: true    
        ports:     
            websecure:       
                 tls:          
                   enabled: true    
        podAnnotations:      
            prometheus.io/port: "8082"     
            prometheus.io/scrape: "true"    
        providers:     
            kubernetesIngress:        
                publishedService:          
                    enabled: true   
        priorityClassName: "system-cluster-critical"   
        image:     
            name: "rancher/mirrored-library-traefik"     
            tag: "2.6.1"    
        tolerations:   
        - key: "CriticalAddonsOnly"      
          operator: "Exists"   
        - key: "node-role.kubernetes.io/control-plane"      
          operator: "Exists"     
          effect: "NoSchedule"    
        - key: "node-role.kubernetes.io/master"      
          operator: "Exists"     
          effect: "NoSchedule"

通常而言,目前支持以下 2 种模式,具体:

1、入口

此模式为默认推荐的方式。我们通过某种方式创建集群,使内部端口 80(Traefik 入口控制器监听)暴露在主机系统上。

2、节点端口 基于集群节点进行特定端口映射。

Servicelb

基于 K3d 中的 Servicelb, klipper-lb 创建新的 Pod,将来自 hostPorts 的流量代理到以下类型的服务端口: LoadBalancer。 在这种情况下,hostPort 是 K3s 容器中的端口,而不是我们的本地主机,因此需要在创建集群时通过 --port 标志添加端口映射。

除了上述功能外, K3d 也具备其他高级特性,诸如使用 Calico 网络策略替代早期的 Flannel、运行 CUDA 工作负载等等。

备注: 如果想在 K3s 容器上运行 CUDA 工作负载,我们则需要自定义容器。 CUDA 工作负载需要 NVIDIA Container Runtime,因此需要将 containerd 配置为使用此运行时。 K3s 容器本身也需要与此运行时一起运行。 如果 使用的是 Docker,则可以安装 NVIDIA Container Toolkit。

接下来,我们了解一下 K3d 的安装部署以及所映射的相关网络模型。为了尽可能地融入社区, K3d 使用 “Server” 和 “Agent” 两个词来设计 “Master” 和 “Worker” 节点。通常,基于 K3d 所构建的本地 Kubernetes 集群环境,主要涉及以下:

1、所创建的每个集群现在将生成至少 2 个容器:1 个负载均衡器和 1 个“服务器”节点。

负载均衡器将成为 Kubernetes API 的接入点,因此即使对于多服务器集群,我们也只需要公开一个 Api 端口,然后负载均衡器将负责将我们的请求代理到正确的服务器节点。(当然,若不使用此项,可以使用 --no-lb 标志进行禁用)

2、 采用“名词动词”句法。 这一重大更改使得添加新名词(即 K3d 托管对象)变得更加容易,并且与许多其他云原生 CLI(例如 Gcloud、AWScli、AZURE cli、...)类似,并且还提供了更清晰的 CLI 层次结构。

3、当一个新的服务器节点被添加至集群时,支持多服务器集群(dqlite)和热重载配置。

4、独立的集群处理节点。

5、基本的插件支持系统及丰富的命令行操作。

现在创建一个带有1个Loadbalancer 和1节点(具有服务器和代理的角色)的简单群集,名称为“Devops Cluster”,具体命令行操作如下所示:

[leonli@192 ~] % k3d version
k3d version v5.3.0
k3s version v1.22.6-k3s1 (default)
[leonli@192 ~] % docker version
Client: 
  Cloud integration: v1.0.22
  Version:           20.10.12 
  API version:       1.41
  Go version:       go1.16.12 
  Git commit:        e91ed57
  Built:             Mon Dec 13 11:46:56 2021 
  OS/Arch:           darwin/arm64
  Context:           default 
  Experimental:      true  

Server: Docker Desktop 4.5.0 (74594)
  Engine:  
    Version:          20.10.12 
    API version:      1.41 (minimum version 1.12) 
    Go version:       go1.16.12  
    Git commit:       459d0df  
    Built:            Mon Dec 13 11:43:07 2021  
    OS/Arch:          linux/arm64  
    Experimental:     false 
  containerd:  
    Version:          1.4.12  
    GitCommit:        7b11cfaabd73bb80907dd23182b9347b4245eb5d 
  runc:  
    Version:          1.0.2  
    GitCommit:        v1.0.2-0-g52b36a2 
  docker-init:  
    Version:          0.19.0  
    GitCommit:        de40ad0


[leonli@192 ~] % k3d cluster create devops-cluster --port 8080:80@loadbalancer --port 8443:443@loadbalancer --api-port 6443 --servers 1 --agents 1
INFO[0000] portmapping '8080:80' targets the loadbalancer: defaulting to [servers:*:proxy agents:*:proxy] 
INFO[0000] portmapping '8443:443' targets the loadbalancer: defaulting to [servers:*:proxy agents:*:proxy] 
INFO[0000] Prep: Network                               
INFO[0000] Created network 'k3d-devops-cluster'       
INFO[0000] Created image volume k3d-devops-cluster-images
INFO[0000] Starting new tools node...                  
INFO[0004] Pulling image 'docker.io/rancher/k3d-tools:5.3.0' 
INFO[0006] Creating node 'k3d-devops-cluster-server-0'  
INFO[0010] Pulling image 'docker.io/rancher/k3s:v1.22.6-k3s1'
INFO[0010] Starting Node 'k3d-devops-cluster-tools'     
INFO[0018] Creating node 'k3d-devops-cluster-agent-0'   
INFO[0018] Creating LoadBalancer 'k3d-devops-cluster-serverlb'
INFO[0023] Pulling image 'docker.io/rancher/k3d-proxy:5.3.0' 
INFO[0033] Using the k3d-tools node to gather environment information 
INFO[0034] Starting cluster 'devops-cluster'            
INFO[0034] Starting servers...                         
INFO[0034] Starting Node 'k3d-devops-cluster-server-0'  
INFO[0038] Starting agents...                         
INFO[0038] Starting Node 'k3d-devops-cluster-agent-0'  
INFO[0045] Starting helpers...                          
INFO[0045] Starting Node 'k3d-devops-cluster-serverlb'  
INFO[0052] Injecting records for hostAliases (incl. host.k3d.internal) and for 3 network members into CoreDNS configmap... 
INFO[0054] Cluster 'devops-cluster' created successfully! 
INFO[0054] You can now use it like this:              
kubectl cluster-info

此时,依据日志输出提示,运行 kubectl cluster-info 查看下当前集群的信息,如下所示:

[leonli@192 ~] % kubectl cluster-info
Kubernetes control plane is running at https://0.0.0.0:6443
CoreDNS is running at https://0.0.0.0:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxyMetrics-server is running at https://0.0.0.0:6443/api/v1/namespaces/kube-system/services/https:
metrics-server:https/proxy

然后,我们借助 docker ps 命令来看一下创建的容器底层相关信息,具体如下所示:

[leonli@192 ~] % docker ps
CONTAINER ID   IMAGE                      COMMAND                  CREATED         STATUS         PORTS                                                                 NAMES
52ae7eedb2f6   rancher/k3d-proxy:5.3.0    "/bin/sh -c nginx-pr…"   6 minutes ago   Up 6 minutes   0.0.0.0:6443->6443/tcp, 0.0.0.0:8080->80/tcp, 0.0.0.0:8443->443/tcp   k3d-devops-cluster-serverlb
c147f033af88   rancher/k3s:v1.22.6-k3s1   "/bin/k3d-entrypoint…"   7 minutes ago   Up 6 minutes                                                                         k3d-devops-cluster-agent-0
3e1e97081296   rancher/k3s:v1.22.6-k3s1   "/bin/k3d-entrypoint…"   7 minutes ago   Up 6 minutes                                                                         k3d-devops-cluster-server-0

接下来,我们基于所创建的集群信息,来梳理一下所配置的端口映射逻辑关系:

--port 8080:80@loadbalancer 会将本地的 8080 端口映射到 Loadbalancer 的 80 端口,然后 Loadbalancer 接收到 80 端口的请求后,会代理到所有的 K8s 节点。

--api-port 6443 默认提供的端口号,K3s 的 Api-Server 会监听 6443 端口,主要是用来操作 Kubernetes API 的,即使创建多个 Master 节点,也只需要暴露一个 6443 端口,Loadbalancer 会将请求代理分发给多个 Master 节点。

如果我们期望通过 NodePort 的形式暴露服务,也可以基于实际的业务场景来自定义一些端口号映射到 Loadbalancer 来暴露 K8s 的服务,当然,前提是如果不想使用 Ingress Controller 的话。相关命令行可参考如下命令行:

-p 20080-30080:20080-30080@loadbalancer

此时,基于 K3d 所创建的名称为 Devops Cluster 的 本地集群网络拓扑如下所示:

现在,一个完整的本地 K8s 集群已部署 Ok,接下来,我们通过创建一个简单的 Nginx 实例进行验证,具体如下所示:

[leonli@192 ~] % kubectl create deployment nginx --image=nginx
deployment.apps/nginx created
[leonli@192 ~] % kubectl create service clusterip nginx --tcp=80:80
service/nginx created
[leonli@192 ~] % cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:  
     name: nginx  
     annotations:    
         ingress.kubernetes.io/ssl-redirect: "false"
spec:  
    rules: 
    - http:      
         paths:      
         - path: /      
           backend:          
               serviceName: nginx          
               servicePort: 80
EOF

创建一个默认由 Traefik 1.x 作为 Ingress Controller 的 Ingress,我们可以直接访 问 http://localhost:8080/,即可看到 Nginx 相关信息。

其实,从本质而言,K3d 是一款出色的工具,其不仅结合了简单、极轻、模块化和功能,同时也解决了更为复杂的需求。除此之外,Rancher 团队再次出色地重写了 K3d,使得在一台机器上运行具有不同拓扑的 K3s Kubernetes 集群的多个实例变得非常容易、模块化、简单和高效。

如果觉得我的文章对您有用,请点赞。您的支持将鼓励我继续创作!

2

添加新评论0 条评论

Ctrl+Enter 发表

作者其他文章

相关文章

相关问题

相关资料

X社区推广