• 手动搭建K8S集群


    阅读本文前默认您已经了解k8s相关知识,适用于想快速部署进行开发

    1.环境准备

    1.1安装虚拟机

    准备三台以上Linux服务器(虚拟机) 我这里使用centos7.6作为镜像文件创建三台虚拟机

    配置要求:2G以上\30G硬盘\2颗cpu核心

    image.png

    1.2系统初始化

    以下操作没有特殊说明默认在每台服务器上都执行命令

    关闭防火墙

    systemctl stop firewalld
    systemctl disable firewalld
    

    关闭 selinux

    sed -i 's/enforcing/disabled/' /etc/selinux/config
    setenforce 0
    

    关闭 swap

    swapoff -a
    sed -ri 's/.*swap.*/#&/' /etc/fstab
    

    根据规划设置主机名 这里针对不同虚拟机设置不同名称

    hostnamectl set-hostname <hostname>
    

    master

    image.png

    node1

    image.png

    node2

    image.png

    在 master节点 添加 其它两个节点hosts

    cat >> /etc/hosts << EOF
    192.168.182.128 k8smaster
    192.168.182.129 k8snode1
    192.168.182.130 k8snode2
    EOF
    

    设置网络

    cat > /etc/sysctl.d/k8s.conf << EOF
    net.bridge.bridge-nf-call-ip6tables = 1
    net.bridge.bridge-nf-call-iptables = 1
    EOF
    

    让配置生效

    sysctl --system
    

    同步服务器时间

    yum install ntpdate -y
    ntpdate time.windows.com
    

    1.3安装依赖环境

    每台服务器安装Docker

    wget https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo -O /etc/yum.repos.d/docker-ce.repo
    yum -y install docker-ce-18.06.1.ce-3.el7
    systemctl enable docker && systemctl start docker
    

    修改docker源

    cat > /etc/docker/daemon.json << EOF
    {
      "registry-mirrors": ["https://b9pmyelo.mirror.aliyuncs.com"]
    }
    EOF
    

    修改k8s的阿里yum源

    cat > /etc/yum.repos.d/kubernetes.repo << EOF
    [kubernetes]
    name=Kubernetes
    baseurl=https://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64
    enabled=1
    gpgcheck=0
    repo_gpgcheck=0
    gpgkey=https://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg https://mirrors.aliyun.com/kubernetes/yum/doc/rpm-package-key.gpg
    EOF
    

    安装kubeadm,kubelet和kubectl

    yum install -y kubelet-1.18.0 kubeadm-1.18.0 kubectl-1.18.0
    systemctl enable kubelet
    

    2.部署k8s节点

    在master节点启动相关组件 注意把对应ip改成你的master节点的ip(192.168.182.128)

    kubeadm init --apiserver-advertise-address=192.168.182.128 --image-repository registry.aliyuncs.com/google_containers --kubernetes-version v1.18.0 --service-cidr=10.96.0.0/12 --pod-network-cidr=10.244.0.0/16
    

    执行完毕查看结果 会看到successfully!下边有一段脚本

    Your Kubernetes control-plane has initialized successfully!

    To start using your cluster, you need to run the following as a regular user: mkdir -pHOME/.kube sudo cp -i /etc/kubernetes/admin.confHOM**E/.kubesudoc**pi/etc/kubernete**s/admin.con**fHOME/.kube/config sudo chown(id -u)i**du):(id -g) $HOME/.kube/config You should now deploy a pod network to the cluster. Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ Then you can join any number of worker nodes by running the following on each as root: kubeadm join 192.168.182.128:6443 --token 5qvw4s.b7fd0vl7gg9gc600
    --discovery-token-ca-cert-hash sha256:b18f63757d14f9d26b853b8e94bda10d4751c988c58c4f9da5a4e7dd7df1d141

    以下操作基于上边安装成功的信息来操作(执行的时候为你安装成功的脚本信息)

    我们复制上边的脚本在master节点执行

      mkdir -p $HOME/.kube
      sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
      sudo chown $(id -u):$(id -g) $HOME/.kube/config
    

    然后看上边最后一段脚本 在node节点 使用kubeadm join命令把node加入到master节点

    kubeadm join 192.168.182.128:6443 --token 5qvw4s.b7fd0vl7gg9gc600 \
        --discovery-token-ca-cert-hash sha256:b18f63757d14f9d26b853b8e94bda10d4751c988c58c4f9da5a4e7dd7df1d141 
    

    注意 默认token有效期为24小时,当过期之后,该token就不可用了。这时就需要重新创建token,操作如下:

    kubeadm token create --print-join-command
    

    部署CNI网络插件

    下载这个yml文件之后修改下名字

    wget https://blog.ddddddddd.top/upload/2021/06/kube-flannel-8395a7b9f4e24e3a874dab894e425dc6.yaml
    mv kube-flannel-8395a7b9f4e24e3a874dab894e425dc6.yaml kube-flannel.yaml
    kubectl apply -f kube-flannel.yaml
    

    查看集群状态 使用kubectl命令查看节点状态

    kubectl get nodes
    
    NAME STATUS ROLES AGE VERSION
    k8smaster Ready master 64m v1.18.0
    k8snode1 Ready 57m v1.18.0
    k8snode2 Ready 56m v1.18.0

    3.集群测试

    使用deployment创建一个nginx应用

    kubectl create deployment nginx --image=nginx
    

    查看应用的状态

    kubectl get po
    

    image.png

    使用expose对外暴露访问端口

    kubectl expose deployment nginx --port=80 --type=NodePort
    

    查看暴露的端口号

    kubectl get pod,svc
    

    image.png

    在浏览器中随便找一个集群的NodeIP进行访问 192.168.182.128:31741 192.168.182.129:31741 192.168.182.130:31741

    image.png

    image.png

    4.Yml文件

    上边我们已经把集群搭建起来并使用命令创建了一个可以对外提供的服务,但是如果我们要创建一些比较复杂的大型应用,这种创建方式是不可取的.总不能每次创建应用都要手敲一大串命令来执行把~~

    我们在启动java程序的时候或者跑一个批处理任务的时候,你是不是要写很多变量参数供程序来读取并执行,k8s中的大部分资源都可以通过yml文件来配置.

    4.1简单的yml文件什么样?

    人类的创造力是最神奇的,但是人类的模仿能力也是很强大的! 我们可以先看别人写的yml文件是什么样子,然后再模仿去写,久而久之...

    我先列一下yml中一些必要的字段解释

    参数名 字段类型 说明
    version String K8s API的版本,使用kubectl api-versions 可以查询到,目前基本都是基于v1
    kind String 指当前的yml定义的资源类型和角色,例如:pod、service
    metadata Object 元数据对象,固定值写metadata
    metadata.name String 元数据对象的名字,例如Pod的名字
    metadata.namespace String 元数据对象的命名空间,用来隔离资源,例如default、自定义xxx
    Spec Object 详细定义对象,固定值写Spec
    Spec.container[] list 这里是spec对象的容器列表定义
    Spec.container[].name String 定义容器的名字
    Spec.container[].image String 镜像的名称

    上边这些只是最基本的参数,k8s中的参数有超级多!用到的时候可以去搜索引擎上边搜索对应的含义

    4.2如何使用yml文件?

    我们在搭建集群的时候使用 kubectl create 创建了一个nginx的服务, 这个命令其实就是帮我们写了一个yml,并且执行了.

    我们使用命令再创建一个nginx的服务但是不让运行,导出它的yml看一下

    kubectl create deployment app-nginx --image=nginx -o yaml --dry-run > nginx.yaml
    

    可以根据我们上边列出的yml字段和下边的对照一下

    [root@k8smaster ~]# cat nginx.yaml 
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      creationTimestamp: null
      labels:
        app: app-nginx
      name: app-nginx
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: app-nginx
      strategy: {}
      template:
        metadata:
          creationTimestamp: null
          labels:
            app: app-nginx
        spec:
          containers:
          - image: nginx
            name: nginx
            resources: {}
    status: {}
    

    然后我们就可以基于这个yml文件修改一些东西并使用apply命令来创建这个资源

    kubectl apply -f nginx.yaml
    

    可以看到我们又创建一个app-nginx的服务 image.png

    5.Pod详解

    Pod 是 k8s 系统中可以创建和管理的最小单元, 是资源对象模型中由用户创建或部署的最

    小资源对象模型, 也是在 k8s 上运行容器化应用的资源对象, 其他的资源对象都是用来支 撑或者扩展 Pod 对象功能的, 比如控制器对象是用来管控 Pod 对象的, Service 或者 Ingress 资源对象是用来暴露 Pod 引用对象的, PersistentVolume 资源对象是用来为 Pod 提供存储等等, k8s 不会直接处理容器, 而是 Pod, Pod 是由一个或多个 container 组成 Pod 是 Kubernetes 的最重要概念, 每一个 Pod 都有一个特殊的被称为” 根容器“的 Pause 容器。 Pause 容器对应的镜 像属于 Kubernetes 平台的一部分, 除了 Pause 容器, 每个 Pod 还包含一个或多个紧密相关的用户业务容器

    太长不看,我们都知道容器可以通过docker来创建,创建出来的容器是单进程的,pod就是一组容器的集合,是多进程了.通俗理解就是k8s不管理单独的容器,它管理的是一组容器,也就是一个pod,一般情况下,我们一个pod就定义一个容器.所以也可以理解成k8s管理pod就是管理一个容器一个服务.我们看张图就直观了

    image.png

    5.1 Pod是什么?

    我们假设现在有个应用,里边用了mysql、redis(非传统部署) 那我们用k8s怎么来启动这样的应用呢, 我们定义pod资源,里边有三个容器,第一个是我们应用自身的程序容器,第二个是mysql的容器,第三个是redis的容器.这样一组容器的集合就是一个POD,我们在启动应用的时候,就会启动是三个服务三个进程,我们只需要针对Pod来进行管理就行了~一般我们一个pod里边只定义一个容器即可.

    5.2 Pod的生命周期

    我们在前边创建nginx的时候也看到了pod的状态为Running,其实它有很多状态

    状态 说明
    Pending API Server已经创建了该Pod,但Pod中的一个或多个容器的镜像还没有穿件,准备过程
    Running Pod内所有的容器已创建,且至少一个容器处于运行状态、正在启动状态。正在重启状态
    Completed Pod内所有的容器均成功执行退出,且不会再重启
    Failed Pod内所有容器均已退出,但至少一个容器退出失败
    Unknown 由于某种原因无法获取Pod状态,例如网络通信不佳

    5.3 Pod的重启策略

    以上边的nginx配置为例 我们设置restartPolicy的策略

        spec:
          containers:
          - image: nginx
            name: nginx
          restartPolicy: Never
    
    重启策略 说明
    Always 当容器失效时,由kubelet自动重启该容器
    OnFailure 当容器终止运行且退出吗不为0时,由kubelet自动重启该容器
    Never 不论容器运行状态如何,kubelet都不会重启该容器

    5.4 Pod的镜像拉取策略

    还是来看我们之前创建nginx的deployment时候的yml文件 在containers这个属性下边 我们可以设置imagesPullPolicay的策略

        spec:
          containers:
          - image: nginx
            name: nginx
    	imagePullPolicy: Always
            resources: {}
    
    镜像拉取策略 说明
    IfNotPresent 默认值, 镜像在宿主机上不粗糙你在的时候才会拉取
    Always 每次创建Pod都会重新拉取一次镜像
    Never Pod 永远不会主动拉取这个镜像

    5.4针对Pod的资源配置

    每个 Pod 都可以对其能使用的服务器上的计算资源设置限额, Kubernetes 中可以设置限额

    的计算资源有 CPU 与 Memory 两种, 其中 CPU 的资源单位为 CPU 数量,是一个绝对值而非相 对值。 Memory 配额也是一个绝对值, 它的单 位是内存字节数。 Kubernetes 里, 一个计算资源进行配额限定需要设定以下两个参数: Requests 该资源最 小申请数量, 系统必须满足要求 Limits 该资源最大允许使用的量, 不能突破, 当容器试 图使用超过这个量的资源时, 可能会被 Kubernetes Kill 并重启

    说人话就是对pod进行一些资源的设置(cpu 内存),在k8s调度节点的时候按照条件来寻找 还是以刚刚的nginx配置文件来实例

        spec:
          containers:
          - image: nginx
            name: nginx
            resources: 
    	  requests:
    	    memory: "64Mi"
    	    cpu: "250m"
    	  limits:
    	    memory: "128Mi"
    	    cpu: "500m"
    

    按照上边在resources属性中设置 requests和limits的值 这里的cpu单位是绝对值, 250m代码使用0.25核 这里的配置指明,k8s在调度的时候,这个pod最低要使用0.25核和64M内存的资源, 下边的是最大限制

    5.5 Pod的健康检查

    我们在前边知道了pod的几种状态. 我们详细看下Running状态的说明 |Pod内所有的容器已创建,且至少一个容器处于运行状态、正在启动状态。正在重启状态| 假设我们的容器已经创建,并且正在启动,但是数据库连接失败了.这个时候我们看pod状态是正常的 但是以应用层面来说,这个应用是无法对外提供服务的.所以我们得有一种策略从应用层面检查应用是否正常 一般生产环境我们可以通过检查httpGet方式,向服务发送http请求,看响应码是否正常来决定服务是否正常

    我们还是以nginx的yml来配置说明

        spec:
          containers:
          - image: nginx
            name: nginx
          livenessProbe:
    	httpGet:
    	  path: /index
    	initialDelaySeconds: 5
    	periodSecondes: 5
    	failureThreshold: 3
    
    策略 说明
    livenessProbe 如果检查失败,就杀死容器,根据pod的restartPolicay来操作
    readinessProbe 如果检查失败,K8s会把pod从service endpoints中剔除

    Probe支持三种检查方法

    方法 说明
    httpGet 发送HTTP请求,返回200-400范围状态码为成功
    exec 执行shell命令返回状态码是0为成功
    tcpSocket 发起TCP Socket建立成功

    每种方式通用的参数含义

    参数 说明
    initialDelaySeconds 容器启动后开始探测之前需要等待多少秒,如果应用启动一般30s的话,就设置为30s
    periodSeconds 执行探测的频率(多少秒执行一次),默认为10s,最小值为1
    successThreshold 探针失败后,最少连续多少次才视为成功,默认值为1。最小值为1
    failureThreshold 最少连续多少次失败才视为失败。默认值为3,最小值为1

    5.6 Pod的调度策略

    我们在前边理解了Pod的资源配置,我们可以配置Pod的最小调度cpu和内存, 其实这个就算是影响Pod的调度策略了.它只会去寻找满足条件的Node来进行部署。

    5.6.1 节点选择器

    我们以nginx配置为例 可以看到下边我们配置了两个nginx的yml 我们用nodeSelector标签配置了(env:xxx)这个值, 代表将我们的pod调度到具有env标签的node机器 第一个调度到具有env标签并且值为dev的Node节点上 第二个调度到具有env标签并且值为prod的Node节点上

        spec:
          nodeSelector:
    	env:dev
          containers:
          - image: nginx
            name: nginx
        spec:
          nodeSelector:
    	env:prod
          containers:
          - image: nginx
            name: nginx
    

    那Node的节点又是如何设置的呢? 我们可以使用label命令来为node打标签如下

    kubectl label node k8snode1 env=dev
    kubectl label node k8snode2 env=prod
    
    kubectl get nodes k8snode1  --show-labels
    kubectl get nodes k8snode2  --show-labels
    

    这样我们就分别为两台Node节点打上了不同的标签,通过命令查看node节点具有的标签

    5.6.2 节点亲和性

    节点亲和性也和node的标签关联,但是和上边的节点选择器不太一样 依然是用nginx的配置文件来说明 这个nodeAffinity标签的属性着实有点多,但是我们只需要关注里边的matchExpressions标签的值即可, 先说一下两种亲和性的特点

    亲和性 说明
    requiredDuringSchedulingIgnoredDuringExcution 硬亲和性,如果没有满足matchExpressions条件,那就一直调度寻找
    preferredDuringSchedulingIgnoreDuringExcution 软亲和性,如果没有满足matchExpressions条件,那别的节点也可以

    结合下边的配置文件来看

        spec:
          arrinity:
    	nodeAffinity:
    	  requiredDuringSchedulingIgnoredDuringExcution:
    	    nodeSelectorTerms:
    	    - matchExpressions:
    	      - key: env
    	        operator: In
    		values:
    		- dev
    		- dev1
          containers:
          - image: nginx
            name: nginx
    

    寻找带有标签env并且值为dev或者dev1的来调度, 没有的话Pod的状态就一直为Pending

        spec:
          arrinity:
    	nodeAffinity:
    	  preferredDuringSchedulingIgnoreDuringExcution:
    	  - weight:1
    	    preferencd:
    	      matchExpressions:
    	      - key: group
    		operator: In
    		values:
    		- public
          containers:
          - image: nginx
            name: nginx
    

    寻找带有标签group并且值为public的来调度, 找一遍没有话,再根据其它调度规则来调度。

    常用的operator操作符有 In、NotIn、Exists、Gt、Lt、DonseNotExists

    5.6.3 污点策略

    上边我们说的几种方法都是基于Pod来进行配置的,还有一种就是设置Node的污点属性主动来管理Pod的调度规则

    我们先看一下master节点和非master节点的污点属性

    [root@k8smaster ~]# kubectl describe node k8smaster | grep Taint
    Taints:             node-role.kubernetes.io/master:NoSchedule
    [root@k8smaster ~]# kubectl describe node k8snode1 | grep Taint
    Taints:             <none>
    [root@k8smaster ~]# kubectl describe node k8snode2 | grep Taint
    Taints:             <none>
    

    可以看到master节点的Taint属性有个 node-role.kubernetes.io/master:NoSchedule 值 先说以下污点的三个类型

    污点类型 说明
    NoSchedule 一定不会调度
    PreferNoSchdule 尽量不被调度
    NoExecute 不会被调度,并且驱逐Node已有的Pod

    所以master节点有NoSchedule属性 pod一般不会调度到master节点,为什么说一般不会呢,后边再说

    如何给Node打污点

    使用taint命令给node1节点打上NoSchedule类型的污点

    kubectl taint node k8snode1 group=public:NoSchedule
    

    污点容忍 上边我们NoSchedule类型污点一定不会被调度,那我为什么说master节点一般不会被调度呢 我们还是以nginx配置文件来说明 以刚刚node1节点为例

        spec:
          tolerations:
          - key: "group"
            operator: "Equal"
    	value: "public"
    	effect: "NoSchedule"
          containers:
          - image: nginx
            name: nginx
    

    上边的tolerations代表 我们可以调度到污点类型为NoSchedule key为group value为public的,操作符同上边的也有很多种

    关于Pod基本策略我们就先了解到这里, 我们在创建nginx的时候用到了kubectl create deployment 命令,那这个deployment是什么呢?我们下边来看看

    6.Controller

    我们知道k8s号称容器编排工具,具备容灾、动态扩缩容等等实现自动化运维的功能,那么我们在前边已经认识到了Pod的概念,Controller就是更高层面的管理运行容器的对象. controller有很多种,我们一般现在用的就是deployment来部署应用

    6.1 使用deployment部署应用

    我们还是以nginx来为例,使用deployment进行部署应用

    创建yml模板文件

    kubectl create deployment nginx --image=nginx --dry-run -o yaml > nginx.yaml
    

    我们来对这个yml分析一下 kind代表资源类型:Deployment metadata中的name属性代表资源的名称:nginx

    spec中就是一些调度的属性了 replicas是指运行几个副本,简单理解一个副本就对应一个Pod,我们在这里修改为3个 deployment与pod之间是通过标签来关联的。 也就是selector.matchLabels.app 这个属性 与containers[n].name 这个属性关联, 这里都为nginx

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      creationTimestamp: null
      labels:
        app: nginx
      name: nginx
    spec:
      replicas: 3
      selector:
        matchLabels:
          app: nginx
      strategy: {}
      template:
        metadata:
          creationTimestamp: null
          labels:
            app: nginx
        spec:
          containers:
          - image: nginx
            name: nginx
            resources: {}
    status: {}
    

    修改之后我们使用命令创建这个副本

    kubectl apply -f nginx.yaml
    

    查看状态

    [root@k8smaster k8s]# kubectl get deploy
    NAME    READY   UP-TO-DATE   AVAILABLE   AGE
    nginx   3/3     3            3           99s
    [root@k8smaster k8s]# kubectl get po
    NAME                    READY   STATUS    RESTARTS   AGE
    nginx-f89759699-gz8dd   1/1     Running   0          101s
    nginx-f89759699-l9n6z   1/1     Running   0          101s
    nginx-f89759699-zt547   1/1     Running   0          101s
    

    我们创建完副本之后,这些pod只能内部访问 我们要通过service来对外暴露端口 我们创建一个service的yml文件看一下

    kubectl expose deployment nginx --port=80 --type=NodePort --target-port=80 --name=nginx --dry-run -o yaml > nginx-svc.yaml
    

    其实yml里边的内容就是我们刚刚输入的命令参数 kind代表资源类型,是Service metadata定义Service的名字 spec定义一些规则 port代表内部通信的端口 protocol代表传输协议 targetPort代表容器内的端口,nginx暴漏的是80端口 selector.app 与pod标签一致 type代表以NodePort方式暴漏,有很多种 具体的属性可以参考文末的参考链接

    apiVersion: v1
    kind: Service
    metadata:
      creationTimestamp: null
      labels:
        app: nginx
      name: nginx
    spec:
      ports:
      - port: 80
        protocol: TCP
        targetPort: 80
      selector:
        app: nginx
      type: NodePort
    status:
      loadBalancer: {}
    

    我们使用命令创建这个Service

    kubectl apply -f nginx-svc.yaml
    

    查看状态

    [root@k8smaster k8s]# kubectl get svc
    NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
    kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP        4d16h
    nginx        NodePort    10.104.57.126   <none>        80:30371/TCP   11s
    

    通过集群的任意Node节点IP+30371端口来访问服务 192.168.182.128:30371 192.168.182.129:30371 192.168.182.130:30371

    image.png

    6.2 版本升级

    我们再来看下刚刚创建的deployment的yml文件 在spec.containers[n].image属性是填写镜像名称的,这里我们没有指定版本就是默认拉取latest最新版本的 我们现在把刚刚创建的depolyment删掉,并指定nginx的版本来创建一下

        spec:
          containers:
          - image: nginx
            name: nginx
            resources: {}
    

    删除deploy和svc

    kubectl delete deploy nginx
    kubectl delete svc nginx
    

    修改nginx版本指定为1.20

        spec:
          containers:
          - image: nginx:1.20
            name: nginx
            resources: {}
    

    创建nginx

    kubectl apply -f nginx.yaml
    

    创建svc

    kubectl apply -f nginx-svc.yaml
    

    我们可以通过nodePort端口来访问nginx查看版本为1.20 image.png

    我们现在使用命令升级nginx的版本到1.21

    kubectl set image deployment nginx nginx=nginx:1.21
    

    查看升级结果为 deployment "nginx" successfully rolled out

    kubectl rollout status deployment nginx
    

    我们看一下nginx的版本

    image.png

    如果你在执行完升级命令之后去查看pod的状态你会发现你设置的副本数为3,但是当前的pod绝对大于3个,但是状态可能不同. 这个升级怎么理解呢,就像一个队列,先进先出,三个老版本的pod,当有一个新版本的pod起来之后,就会把老版本的剔除掉,知道所有pod都更新换代!这期间,服务不会停止。这就是不停机维护升级版本。

    6.3 版本回退

    生产情况下,我们把版本升级之后,有用户返回新版本好像有BUG!影响很大,这个时候怎么办。k8s也提供了版本回退机制,你可以一键回退到上个版本

    我们先看一下版本的管理 有两个版本,第一个就是最开始的1.20,第二个就是我们升级的1.21

    [root@k8smaster k8s]# kubectl rollout history deployment nginx
    deployment.apps/nginx 
    REVISION  CHANGE-CAUSE
    1         <none>
    2         <none>
    

    回滚到上一个版本

    kubectl rollout undo deployment nginx
    

    这次我截了个图 可以明显看到,下边三个是新创建的pod, 已经有两个处于Running状态了,那就证明上边必定有两个已经停止了服务处于Terminating状态,一会就会消失了~ 和上边升级版本的过程是一样的,回退也是不停机的 image.png 查看回滚结果

    [root@k8smaster k8s]# kubectl rollout status deployment nginx
    deployment "nginx" successfully rolled out
    

    我们既然有了版本管理的概念,那我们能否回滚到某个指定版本呢?是可以的

    回滚到指定版本 通过--to-revision回滚到某个版本

    kubectl rollout undo deployment nginx --to-revision=2
    

    6.4 弹性伸缩

    当生产环境的服务访问量高峰期时,我们可以动态的增加容器的数量来分担流量

    kubectl scale deployment nginx --replicas=5
    

    6.5 一些特殊的Controller

    当我们创建副本数量大于1个时,这些pod启动是无序的,而且每个pod都是一样的,可以进行随意伸缩,我们如果想为每一个pod指定一个唯一标识符,该如何做?如果我们只需要这个pod运行一次就停止,或者定时运行一个pod该怎么做?

    6.5.1 StatefulSet

    我们上边创建服务均使用的Deployment,我们可以使用StatefulSet来创建Pod 我们看一下这个yml,中间以 "---"符号隔开代表把两个yml写在一起了,创建的时候使用这一个文件即可

    首先,我们创建了一个Service,它的name是nginx-sfs,最重要的是它的ClusterIP我们设置了None 接下来创建了一个StatefulSet类型的Controller 这个和前边是一样的,只要注意Service和Controller的标签一致即可。

    apiVersion: v1
    kind: Service
    metadata:
      name: nginx-sfs
      labels:
        app: nginx
    spec:
      ports:
      - port: 80
        name: nginx-sfs
      clusterIP: None
      selector:
        app: nginx
    
    ---
    
    apiVersion: apps/v1
    kind: StatefulSet
    metadata:
      name: nginx-sfs
    spec:
      serviceName: nginx-sfs
      replicas: 3
      selector:
        matchLabels:
          app: nginx
      template:
        metadata:
          labels:
            app: nginx
        spec:
          containers:
          - name: nginx
            image: nginx:latest
            ports:
            - containerPort: 80
    

    使用命令创建它,并查看它们的状态 可以看到nginx-sfs的ClusterIP是None pod的name是以nginx-sfs-序列号来创建的,并且会给每个pod分配一个固定的域名,这个域名解析对应的pod内部IP StatefulSet中每个Pod的DNS格式为statefulSetName-{0..N-1}.serviceName.namespace.svc.cluster.local 这一块的东西可以看一下文末的参考链接中的官方文档。

    StatefulSet的作用是很大的,例如可以应用到mysql主从,来确定标识进行读写分离。

    [root@k8smaster k8s]# kubectl apply -f nginx-sfs.yaml 
    service/nginx-sfs created
    statefulset.apps/nginx-sfs created
    [root@k8smaster k8s]# kubectl get svc
    NAME         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
    kubernetes   ClusterIP   10.96.0.1       <none>        443/TCP        4d22h
    nginx        NodePort    10.111.59.136   <none>        80:32526/TCP   11m
    nginx-sfs    ClusterIP   None            <none>        80/TCP         30s
    [root@k8smaster k8s]# kubectl get po
    NAME                    READY   STATUS    RESTARTS   AGE
    nginx-f54648c68-56sqr   1/1     Running   0          44m
    nginx-f54648c68-b7b7d   1/1     Running   0          44m
    nginx-f54648c68-cv58s   1/1     Running   1          5h57m
    nginx-f54648c68-rr2jr   1/1     Running   0          44m
    nginx-f54648c68-xmsr2   1/1     Running   0          44m
    nginx-sfs-0             1/1     Running   0          4m2s
    nginx-sfs-1             1/1     Running   0          3m41s
    nginx-sfs-2             1/1     Running   0          3m19s
    

    6.5.2 DaemonSet

    加入我们有个服务,是收集每台node节点的性能数据,我们要让每台node都部署一个这样的探针,并且后边新加进来的node节点也要自动部署这个探针服务,我们就可以使用DaemonSet

    我们看一下这个yml, kind被标识为DaemonSet,这里边的volumes后边会说,我们创建这个,来收集nginx产生的日志,每台node节点都部署一个.

    apiVersion: apps/v1
    kind: DaemonSet
    metadata:
      name: nginx-ds
      labels:
        app: nginx-ds
    spec:
      selector:
        matchLabels:
          app: nginx-ds
      template:
        metadata:
          labels:
            app: nginx-ds
        spec:
          containers:
          - name: logs
            image: nginx
            ports:
            - containerPort: 80
            volumeMounts:
            - name: varlog
              mountPath: /tmp/log
          volumes:
          - name: varlog
            hostPath:
              path: /var/log
    

    我们使用命令创建它并查看状态

    [root@k8smaster k8s]# kubectl apply -f nginx-ds.yaml 
    daemonset.apps/nginx-ds created
    [root@k8smaster k8s]# kubectl get daemonSet
    NAME       DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
    nginx-ds   2         2         2       2            2           <none>          26s
    [root@k8smaster k8s]# kubectl get po -o wide
    NAME                    READY   STATUS    RESTARTS   AGE     IP            NODE       NOMINATED NODE   READINESS GATES
    nginx-ds-4w8wj          1/1     Running   0          80s     10.244.2.22   k8snode2   <none>           <none>
    nginx-ds-t6q9w          1/1     Running   0          80s     10.244.1.7    k8snode1   <none>           <none>
    

    可以看到每台node节点都部署了一个nginx-ds服务

    6.5.3 Job(一次性任务)

    我们要部署一个批处理任务,执行一次即可怎么做? kind指定为Job类型, 容器使用perl,进行圆周率的输出

    apiVersion: batch/v1
    kind: Job
    metadata:
      name: pi
    spec:
      template:
        spec:
          containers:
          - name: pi
            image: perl
            command: ["perl",  "-Mbignum=bpi", "-wle", "print bpi(2000)"]
          restartPolicy: Never
      backoffLimit: 4
    

    创建并查看结果

    可以看到pod状态为Completed代表pod的程序已经运行完成, 使用get jobs命令查看job也运行完成

    [root@k8smaster k8s]# kubectl get po
    NAME                    READY   STATUS      RESTARTS   AGE
    pi-ltjzl                0/1     Completed   0          3m19s
    [root@k8smaster k8s]# kubectl get jobs
    NAME   COMPLETIONS   DURATION   AGE
    pi     1/1           80s        3m28s
    

    我们使用命令查看pod的日志看下

    [root@k8smaster k8s]# kubectl logs pi-ltjzl
    3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679821480865132823066470938446095505822317253594081284811174502841027019385211055596446229489549303819644288109756659334461284756482337867831652712019091456485669234603486104543266482133936072602491412737245870066063155881748815209209628292540917153643678925903600113305305488204665213841469519415116094330572703657595919530921861173819326117931051185480744623799627495673518857527248912279381830119491298336733624406566430860213949463952247371907021798609437027705392171762931767523846748184676694051320005681271452635608277857713427577896091736371787214684409012249534301465495853710507922796892589235420199561121290219608640344181598136297747713099605187072113499999983729780499510597317328160963185950244594553469083026425223082533446850352619311881710100031378387528865875332083814206171776691473035982534904287554687311595628638823537875937519577818577805321712268066130019278766111959092164201989380952572010654858632788659361533818279682303019520353018529689957736225994138912497217752834791315155748572424541506959508295331168617278558890750983817546374649393192550604009277016711390098488240128583616035637076601047101819429555961989467678374494482553797747268471040475346462080466842590694912933136770289891521047521620569660240580381501935112533824300355876402474964732639141992726042699227967823547816360093417216412199245863150302861829745557067498385054945885869269956909272107975093029553211653449872027559602364806654991198818347977535663698074265425278625518184175746728909777727938000816470600161452491921732172147723501414419735685481613611573525521334757418494684385233239073941433345477624168625189835694855620992192221842725502542568876717904946016534668049886272327917860857843838279679766814541009538837863609506800642251252051173929848960841284886269456042419652850222106611863067442786220391949450471237137869609563643719172874677646575739624138908658326459958133904780275901
    

    6.5.4 CronJob(定时任务)

    每隔一段时间我们就让它执行一次任务的服务怎么部署? 指定kind类型为CronJob schedule参数为cron表达式 这里是每分钟 具体内容是输入 Hello from the Kubernetes cluster

    apiVersion: batch/v1beta1
    kind: CronJob
    metadata:
      name: hello
    spec:
      schedule: "*/1 * * * *"
      jobTemplate:
        spec:
          template:
            spec:
              containers:
              - name: hello
                image: busybox
                args:
                - /bin/sh
                - -c
                - date; echo Hello from the Kubernetes cluster
              restartPolicy: OnFailure
    

    创建并查看结果 我们可以看到已经运行2m31s,上次运行是34s前 看到有两个pod的状态为Completed

    [root@k8smaster k8s]# kubectl get cronjob
    NAME    SCHEDULE      SUSPEND   ACTIVE   LAST SCHEDULE   AGE
    hello   */1 * * * *   False     1        34s             2m31s
    [root@k8smaster k8s]# kubectl get po
    NAME                     READY   STATUS      RESTARTS   AGE
    hello-1623684840-v79g9   0/1     Completed   0          110s
    hello-1623684900-pw8g2   0/1     Completed   0          50s
    

    查看pod内日志

    [root@k8smaster k8s]# kubectl logs hello-1623684900-pw8g2
    Mon Jun 14 15:35:46 UTC 2021
    Hello from the Kubernetes cluster
    

    要注意的是,定期任务,是每到执行时间便创建一个pod来执行任务,执行完pod状态就是Completed

    至此,我们就可以动态的对集群内部的应用进行管理配置,还有几种适用于不同场景的Controller, 其中我们创建了Service来对外暴漏nodePort端口来提供服务,这个Service是什么呢?

    7.Service

    Service 是 Kubernetes 最核心概念, 通过创建 Service,可以为一组具有相同功能的容器应

    用提供一个统一的入口地 址, 并且将请求负载分发到后端的各个容器应用上

    7.1 Service是什么?

    我们先用命令查看一下pod的详细描述 可以看到IP地址都是虚拟的,Pod这个概念时短暂的,只要你node重启或者副本伸缩,这个IP都会发生变化, 我们知道,服务之间通信最基本的都要基于IP+端口来传输数据,那么IP老是变化怎么办?那就是Service做的事情了.

    [root@k8smaster ~]# kubectl get po -o wide
    NAME                    READY   STATUS    RESTARTS   AGE     IP            NODE       NOMINATED NODE   READINESS GATES
    nginx-f54648c68-56sqr   1/1     Running   0          2m39s   10.244.2.19   k8snode2   <none>           <none>
    nginx-f54648c68-b7b7d   1/1     Running   0          2m39s   10.244.1.4    k8snode1   <none>           <none>
    nginx-f54648c68-cv58s   1/1     Running   1          5h15m   10.244.2.18   k8snode2   <none>           <none>
    nginx-f54648c68-rr2jr   1/1     Running   0          2m39s   10.244.2.20   k8snode2   <none>           <none>
    nginx-f54648c68-xmsr2   1/1     Running   0          2m39s   10.244.1.3    k8snode1   <none>           <none>
    

    可以把Service看作是一个服务注册中心,创建的pod信息都被注册到上边,需要通信的时候,就去服务中心中去发现,找到对应的IP建立链接,而且它还可以指定负载策略,五个pod,到底哪个接受请求,就是它做的事情。

    Service和pod如何建立关系呢?我们看一下创建Service时候的yml 可以看到labels.app:nginx 和 selector.app:nginx是一致的, 创建deployment时,我们给的标签也是app:nginx,它们是通过标签来进行关联的。

    apiVersion: v1
    kind: Service
    metadata:
      creationTimestamp: null
      labels:
        app: nginx
      name: nginx
    spec:
      ports:
      - port: 80
        protocol: TCP
        targetPort: 80
      selector:
        app: nginx
      type: NodePort
    status:
      loadBalancer: {}
    

    7.1 Service的三种类型

    类型 说明
    ClusterIP 默认类型,供集群内部使用
    NodePort 对外暴漏端口,供外部应用访问使用
    LoadBalancer 对外访问应用,适用于公有云

    我们看上边Service的yml文件,其实可以看到我们指定了type:NodePort,这个时候创建svc之后,我们就可以使用任意node节点的IP+端口来访问我们的服务了~

    那默认类型怎么理解呢,如果使用ClusterIP类型,那么就默认只能集群内部访问,使用任意node节点内部进行curl请求或者其它连接请求即可访问. 我们可以验证以下,我们把创建好的svc删除

    [root@k8smaster ~]# kubectl delete svc nginx
    service "nginx" deleted
    [root@k8smaster k8s]# vi nginx-svc.yaml 
    [root@k8smaster k8s]# kubectl apply -f nginx-svc.yaml 
    service/nginx created
    [root@k8smaster k8s]# kubectl get svc
    NAME         TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
    kubernetes   ClusterIP   10.96.0.1     <none>        443/TCP   4d22h
    nginx        ClusterIP   10.99.42.89   <none>        80/TCP    7s
    

    可以看到我们查询svc之后,TYPE类型变为ClusterIP,这个时候我们在集群内部任一节点来访问这个IP 可以看到,访问到了nginx服务

    [root@k8snode1 ~]# curl 10.99.42.89
    <!DOCTYPE html>
    <html>
    <head>
    <title>Welcome to nginx!</title>
    <style>
        body {
             35em;
            margin: 0 auto;
            font-family: Tahoma, Verdana, Arial, sans-serif;
        }
    </style>
    </head>
    <body>
    <h1>Welcome to nginx!</h1>
    <p>If you see this page, the nginx web server is successfully installed and
    working. Further configuration is required.</p>
    
    <p>For online documentation and support please refer to
    <a href="http://nginx.org/">nginx.org</a>.<br/>
    Commercial support is available at
    <a href="http://nginx.com/">nginx.com</a>.</p>
    
    <p><em>Thank you for using nginx.</em></p>
    </body>
    </html>
    

    8.Secret

    Secret 解决了密码、 token、 密钥等敏感数据的配置问题, 而不需要把这些敏感数据暴露

    到镜像或者 Pod Spec 中。 Secret 可以以 Volume 或者环境变量的方式使用

    8.1 Secret的三种类型

    类型 说明
    Opaque base64编码格式的Secret,常用于存储密码、密钥等
    kubernetes.io/dockerconfigjson 用来存储私有 docker registry的认证信息
    ServiceAccount 用来访问KubernetesAPI,由Kubernetes自动创建,并且会自动挂在到Pod的/run/secrets/kubernetes.io/serviceaccount目录中

    8.2 如何使用Opaque类型的Secret

    我们在创建容器的时候通常会给容器内传入参数变量,我们通过两种方式来说明如何挂载Secret到Pod中

    8.2.1 以变量形式

    Opaque都是以base64编码来传输的, 我们先编码两个字符串来模拟账号和密码 admin -> YWRtaW4= fangpengbo -> ZmFuZ3Blbmdibw==

    我们看下边的yml文件,kind类型为Secret,name为secret-Opaque,有两个参数一个是username,一个是password,对应的value值是我们上边进行base64的。

    apiVersion: v1
    kind: Secret
    metadata:
      name: secret-opaque
    type: Opaque
    data:
      username: YWRtaW4=
      password: ZmFuZ3Blbmdibw==
    

    创建并查看它

    [root@k8smaster k8s]# kubectl apply -f secret.yaml 
    secret/secret-opaque created
    [root@k8smaster k8s]# kubectl get secret
    NAME                  TYPE                                  DATA   AGE
    secret-opaque         Opaque                                2      15s
    

    我们还是以创建nginx来挂载参数为例 在env属性下边我们挂在两个环境变量,关联到secret-opaque这个secret 分别取username和password为key

    apiVersion: v1
    kind: Pod
    metadata:
      name: mypod
    spec:
      containers:
      - name: nginx
        image: nginx
        env:
          - name: SECRET_USERNAME
            valueFrom:
              secretKeyRef:
                name: secret-opaque
                key: username
          - name: SECRET_PASSWORD
            valueFrom:
              secretKeyRef:
                name: secret-opaque
                key: password
    

    创建之后,进入容器内查看环境变量

    [root@k8smaster k8s]# kubectl apply -f secret-var.yaml 
    pod/mypod created
    [root@k8smaster k8s]# kubectl get po
    NAME                    READY   STATUS    RESTARTS   AGE
    mypod                   1/1     Running   0          44s
    [root@k8smaster k8s]# kubectl exec -it mypod bash
    root@mypod:/# echo $SECRET_USERNAME
    admin
    root@mypod:/# echo $SECRET_PASSWORD
    fangpengbo
    root@mypod:/# 
    

    8.2.2 以配置文件形式挂载

    我们把创建的secret以配置文件的形式直接挂载到pod内部 不一样的地址在于volumeMounts这个属性, mountPath说明挂载到容器内部的哪个路径 volumes就是secret列表

    apiVersion: v1
    kind: Pod
    metadata:
      name: mypod
    spec:
      containers:
      - name: nginx
        image: nginx
        volumeMounts:
        - name: foo
          mountPath: "/etc/foo"
          readOnly: true
      volumes:
      - name: foo
        secret:
          secretName: secret-opaque
    

    创建并进入容器内部查看它

    [root@k8smaster k8s]# kubectl exec -it mypod bash
    root@mypod:/# cd /etc/foo/
    root@mypod:/etc/foo# ls
    password  username
    root@mypod:/etc/foo# cat password 
    fangpengbo
    root@mypod:/etc/foo# cat username 
    admin
    

    9.ConfigMap

    ConfigMap 功能在 Kubernetes1.2 版本中引入, 许多应用程序会从配置文件、 命令行参数

    或环境变量中读取配 置信息。 ConfigMap 给我们提供了向容器中注入配置信息的机 制, ConfigMap 可以被用来保存单个属性, 也 可以用来保存整个配置文件或者 JSON 二进 制大对象

    configMap和我们上个Secret类似,不过是configMap更像一个配置文件,用来保存一些配置信息供我们的pod读取.我们来看下如何使用configMap来挂载到pod中

    9.1 如何使用configMap

    我们现在有一个redis的配置文件如下

    redis.host=127.0.0.1
    redis.port=6379
    redis.password=123456
    

    我们来创建并查看它

    [root@k8smaster k8s]# kubectl create configmap redis-config --from-file=redis.properties
    configmap/redis-config created
    [root@k8smaster k8s]# kubectl get cm
    NAME           DATA   AGE
    redis-config   1      7s
    [root@k8smaster k8s]# kubectl describe cm redis-config
    Name:         redis-config
    Namespace:    default
    Labels:       <none>
    Annotations:  <none>
    
    Data
    ====
    redis.properties:
    ----
    redis.host=127.0.0.1
    redis.port=6379
    redis.password=123456
    
    Events:  <none>
    

    下边我们还是创建一个nginx容器,用volume方式挂载到容器内部 我们可以看到在configMap属性中指定我们刚刚创建的redis-config 创建完pod之后我们输出以下这个配置文件

    apiVersion: v1
    kind: Pod
    metadata:
      name: mypod
    spec:
      containers:
        - name: busybox
          image: busybox
          command: [ "/bin/sh","-c","cat /etc/config/redis.properties" ]
          volumeMounts:
          - name: config-volume
            mountPath: /etc/config
      volumes:
        - name: config-volume
          configMap:
            name: redis-config
    

    创建pod并查看日志

    
    

    下边我们以变量的形式挂载到pod中 我们创建了一个kind类型为ConfigMap的资源 data绑定了两个值

    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: myconfig
      namespace: default
    data:
      special.level: info
      special.type: hello
    

    创建它

    [root@k8smaster k8s]# kubectl apply -f myconfig.yaml 
    configmap/myconfig created
    [root@k8smaster k8s]# kubectl get cm
    NAME           DATA   AGE
    myconfig       2      8s
    

    创建pod挂载 我们挂载到env中

    apiVersion: v1
    kind: Pod
    metadata:
      name: mypod1
    spec:
      containers:
        - name: busybox
          image: busybox
          command: [ "/bin/sh", "-c", "echo $(LEVEL) $(TYPE)" ]
          env:
            - name: LEVEL
              valueFrom:
                configMapKeyRef:
                  name: myconfig
                  key: special.level
            - name: TYPE
              valueFrom:
                configMapKeyRef:
                  name: myconfig
                  key: special.type
      restartPolicy: Never
    

    创建并查看日志

    [root@k8smaster k8s]# kubectl logs mypod1
    info hello
    

    10.Ingress-Nginx

    在前边我们创建了一个pod,通过service对外暴漏端口,就可以通过任意node节点IP+端口访问到应用, 但是生产环境我们肯定是通过域名来访问的,这个时候就要借助Ingress-Nginx来进行代理。

    在创建ingress之前,我们先创建一个pod并且暴漏端口

    [root@k8smaster k8s]# kubectl get po,svc
    NAME                        READY   STATUS      RESTARTS   AGE
    pod/nginx-f54648c68-xmsr2   1/1     Running     1          2d2h
    
    NAME                 TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
    service/nginx        NodePort    10.111.59.136   <none>        80:32526/TCP   2d1h
    

    10.1 创建ingress

    首先下载ingress的ymal文件,官网也有,这里直接提供. ingress-controller.yaml

    使用命令创建就会创建很多东西

    [root@k8smaster k8s]# kubectl apply -f ingress-controller.yaml
    namespace/ingress-nginx created
    configmap/nginx-configuration created
    configmap/tcp-services created
    configmap/udp-services created
    serviceaccount/nginx-ingress-serviceaccount created
    clusterrole.rbac.authorization.k8s.io/nginx-ingress-clusterrole created
    role.rbac.authorization.k8s.io/nginx-ingress-role created
    rolebinding.rbac.authorization.k8s.io/nginx-ingress-role-nisa-binding created
    clusterrolebinding.rbac.authorization.k8s.io/nginx-ingress-clusterrole-nisa-binding created
    deployment.apps/nginx-ingress-controller created
    limitrange/ingress-nginx created
    

    我们查看一下ingress的状态 注意ingress创建了一个叫 ingress-nginx的名称空间

    [root@k8smaster k8s]# kubectl get po -n ingress-nginx
    NAME                                       READY   STATUS    RESTARTS   AGE
    nginx-ingress-controller-766fb9f77-85jqd   1/1     Running   0          54s
    

    10.2 创建ingress的规则

    我们把ingress和刚刚创建的service关联起来 host就是我们要绑定的域名,这里我先用本地host验证以下 下边的serviceName就是我们创建的nginx对外暴露端口的service名称 servicePort就是pod的内部端口

    apiVersion: networking.k8s.io/v1beta1
    kind: Ingress
    metadata:
      name: example-ingress
    spec:
      rules:
      - host: example.nginx.com
        http:
          paths:
          - path: /
            backend:
              serviceName: nginx
              servicePort: 80
    

    创建ingress并查看

    [root@k8smaster k8s]# kubectl apply -f ingress.yaml 
    ingress.networking.k8s.io/example-ingress created
    [root@k8smaster k8s]# kubectl get ing
    NAME              CLASS    HOSTS               ADDRESS   PORTS   AGE
    example-ingress   <none>   example.nginx.com             80      7s
    

    创建之后,我们要通过ingress来访问我们的nginx服务,我们先看下ingress部署到哪台node

    [root@k8smaster k8s]# kubectl get po -n ingress-nginx -o wide
    NAME                                       READY   STATUS    RESTARTS   AGE     IP                NODE       NOMINATED NODE   READINESS GATES
    nginx-ingress-controller-766fb9f77-85jqd   1/1     Running   0          7m45s   192.168.182.130   k8snode2   <none>           <none>
    

    可以看到是在k8snode2节点上,对应的IP是192.168.182.130, 我们在本地host做一下映射测试一下 image.png 然后访问我们映射的域名 image.png 到这里,我们就可以通过ingress来部署域名进行服务的映射了~

  • 相关阅读:
    Aspnetcore2.0中Entityframeworkcore及Autofac的使用(一)(附Demo)
    Aspnetcore2.0中Entityframeworkcore及Autofac的使用(二)(附Demo)(
    Asp.net MVC模型数据验证扩展ValidationAttribute
    Asp.net MVC中如何实现依赖注入(DI)(二)
    Asp.net MVC中如何实现依赖注入(DI)(一)
    Asp.net中接口签名与验签常用方法
    Win10系统安装MongoDB教程及错误代码100解决办法
    MVC导出Excel之NPOI简单使用(一)
    sqlserver merge 操作符
    sqlserver 递归查询
  • 原文地址:https://www.cnblogs.com/vinsent/p/15917739.html
Copyright © 2020-2023  润新知