专栏首页深入理解k8s网络原理深入理解kubernetes(k8s)网络原理之五-flannel原理
原创

深入理解kubernetes(k8s)网络原理之五-flannel原理

深入理解kubernetes(k8s)网络原理之五-flannel原理

flannel有udp、vxlan和host-gw三种模式,udp模式因为性能较低现在已经比较少用到,host-gw我们在前面简单介绍过,因为使用场景比较受限,所以vxlan模式是flannel使用最多的模式,本章我们来介绍一下vxlan模式的原理。

我们在第三篇文章中已经详细介绍过vxlan如何完成跨主机pod通信,所以在这我们主要介绍flannel的几个组件的工作原理,最后也会简要介绍一下udp模式。

在vlan模式下,每个节点会有一个符合cni规范的二进制可执行文件flannel(下面简称flannel-cni),一个以k8s的daemonset方式运行的kube-flannel,下面来分别介绍下它们是干啥的:

flannel-cni

flannel文件存放在每个节点的/opt/cni/bin目录下,这个目录下还有cni官方默认提供的其它插件,这些cni插件分为三类:

  • ipam,负责地址分配,主要有:host-local、dhcp、static
  • main,负责主机和容器网络的编织,主要有:bridge、ptp、ipvlan、macvlan、host-device、
  • meta,其它,主要有:flannel、bandwidth、firewall、portmap、tuning、sbr

这些文件是我们在安装kubeadm和kubelet时自动安装的,如果发现这个目录为空,也可以用下面的命令手动安装:

yum install kubernetes-cni -y

这个文件不做具体的容器网络编织的工作,而是生成其它cni插件需要的配置文件,然后调用其它的cni插件(通常是bridge和host-local),完成主机内容器到主机的网络互通,这个flannel-cni文件的源码已经不在flannel项目上了,而是在cni的plugins中,地址如下:

https://github.com/containernetworking/plugins/tree/master/plugins/meta/flannel

flannel-cni工作流程

kubelet创建一个pod时,先会创建一个pause容器,然后用pause容器的网络命名空间为入参(类似:/var/run/docker/netns/xxxx,用docker inspect nginx|grep Sandbox能获取到),加上其它一些参数,调用/etc/cni/net.d/目录下的配置文件指定的cni插件,内容如下:

cat /etc/cni/net.d/10-flannel.conflist

{
  "name": "cbr0",
  "cniVersion": "0.3.1",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

这个配置文件是kube-flannel启动时复制进去的,我们编写cni时也要生成这个文件

这个文件中指定的cni插件叫flannel,于是kubelet就调用了/opt/cni/bin/flannel文件,这个文件先会读取/run/flannel/subnet.env文件,里面主要包含当前节点的子网信息,内容如下:

cat /run/flannel/subnet.env

FLANNEL_NETWORK=10.244.0.0/16
FLANNEL_SUBNET=10.244.1.1/24
FLANNEL_MTU=1450
FLANNEL_IPMASQ=true

这个文件也是kube-flannel启动时写入的

flannel读取该文件内容后,紧接着会生成一个符合cni标准的配置文件,内容如下:

{
  "cniVersion": "0.3.0",
  "name": "networks",
  "type": "bridge",
  "bridge": "cni0",
  "isDefaultGateway": true,
  "ipam": {
    "type": "host-local",
    "subnet": "10.244.1.0/24",
    "dataDir": "/var/lib/cni/",
    "routes": [{ "dst": "0.0.0.0/0" }]
  }
}

其实可以一步到位,直接生成这个格式的文件放在/etc/cni/net.d/目录下,flannel这样处理应该是为了节点子网发生变化时不用重启kubelet吧

然后像kubelet调用flannel的方式一样调用另一个cni插件bridge,并把上面的配置文件的内容用标准输入的方式传递过去,调用方式如下:

echo '{ "cniVersion": "0.3.0", "name": "network", "type":"bridge","bridge":"cni0", "ipam":{"type":"host-local","subnet": "10.244.1.0/24","dataDir": "/var/lib/cni/","routes": [{ "dst": "0.0.0.0/0" }]}}' | CNI_COMMAND=ADD
CNI_CONTAINERID=xxx 
CNI_NETNS=/var/run/docker/netns/xxxx 
CNI_IFNAME=xxx 
CNI_ARGS='IgnoreUnknown=1;K8S_POD_NAMESPACE=applife;K8S_POD_NAME=redis-59b4c86fd9-wrmr9' 
CNI_PATH=/opt/cni/bin/ 
./bridge

后面我们动手编写cni插件时,可以用上述的方式来模拟kubelet调用cni,这样测试会方便很多

剩余的工作就会由/opt/cni/bin/bridge插件完成,它会:

  • 在主机上创建一个名为cni0的linux bridge,然后把子网的第一个地址(如示例中:10.244.1.1)绑到cni0上,这样cni0同时也是该节点上所有pod的默认网关;
  • 在主机上创建一条主机路由:ip route add 10.244.1.0/24 dev cni0 scope link src 10.244.1.1,这样一来,节点到本节点所有的pod就都会走cni0了;
  • 创建veth网卡对,把一端插到新创建的pod的ns中,另一端插到cni0网桥上;
  • 在pod的ns为刚刚创建的veth网卡设置IP,IP为host-local分配的值,默认网关设置为cni0的IP地址:10.244.1.1;
  • 设置网卡的mtu,这个很关键,跟使用哪种跨节点通信方案相关,如果使用vxlan,一般就是1460,如果是host-gw,就是1500;

然后pod到主机、同主机的pod的通信就完成了,这就是flannel-cni完成的工作,只负责同节点pod的通信,对于跨节点pod通信由kube-flannel完成。

host-local是以写本地文件的方式来标识哪些IP已经被占用,它会在/var/lib/cni/network/host-local/(这个目录其实是上面的dataDir参数指定的)目录下生成一些文件,文件名为已分配的IP,文件内容为使用该IP的容器ID,有一个指示当前已分配最新的IP的文件。

kube-flannel

kube-flannel以k8s的daemonset方式运行,主要负责编织跨节点pod通信,启动后会完成以下几件事情:

  • 启动容器会把/etc/kube-flannel/cni-conf.json文件复制到/etc/cni/net.d/10-flannel.conflist,这个文件是容器启动时从配置项挂载到容器上的,可以通过修改flannel部署的yaml文件来修改配置,选择使用其它的cni插件。
  • 运行容器会从api-server中获取属于本节点的pod-cidr,然后写一个配置文件/run/flannel/subnet.env给flannel-cni用
  • 如果是vxlan模式,则创建一个名为flannel.1的vxlan设备(关闭了自动学习机制),把这个设备的MAC地址和IP以及本节点的IP记录到节点的注解中。
  • 启动一个协程,不断地检查本机的路由信息是否被删除,如果检查到缺失,则重新创建,防止误删导致网络不通的情况。
  • 从api-server或etcd订阅资源变化的事件,维护路由表项、邻居表项、fdb表项

接下来介绍一下当kube-flannel收到节点新增事件时会完成的事情。

假设现在有一个k8s集群拥有master、node1和node2三个节点,这时候新增了一个节点node3,node3的IP为:192.168.3.10,node3上的kube-flannel为node3创建的vxlan设备IP地址为10.244.3.0,mac地址为:02:3f:39:67:7d:f9 ,相关的信息已经保存在节点的annotation上,用kubectl查看node3的节点信息如下:

[root@node1]# kubectl describe node node3

Name:               node3
...
Annotations:        flannel.alpha.coreos.com/backend-data: {"VtepMAC":"02:3f:39:67:7d:f9"}
                    flannel.alpha.coreos.com/backend-type: vxlan
                    flannel.alpha.coreos.com/kube-subnet-manager: true
                    flannel.alpha.coreos.com/public-ip: 192.168.3.10
...
PodCIDR: 10.244.3.0/24

node1上的kube-flannel收到node3的新增事件,会完成以下几件事:

  • 新增一条到10.244.3.0/24的主机路由,并指示通过flannel.1设备走,下一跳为node3上的vxlan设备的IP地址10.244.3.0
ip route add 10.244.3.0/24 via 10.244.3.0 dev flannel.1 onlink
  • 新增一条邻居表信息,指示node3的vxlan设备10.244.3.0的mac地址为:02:3f:39:67:7d:f9,并用nud permanent指明该arp记录不会过期,不用做存活检查:
ip neigh add 10.244.3.0 lladdr 02:3f:39:67:7d:f9 dev flannel.1 nud permanent
  • 新增一条fdb(forwarding database)记录,指明到node3的vxlan设备的mac地址的下一跳主机为node3的ip:
bridge fdb append  02:3f:39:67:7d:f9 dev vxlan0 dst 192.168.3.10 self permanent
  • 如果在配置中启用了Directrouting,那么在这里会判断新增节点与当前节点是否在同一子网,如果是,则前面三步都不会发生,取而代之的是:
ip route add 10.244.3.0/24 via 192.168.3.10 dev eth0 onlink

注意这里的下一跳node3的节点IP,出口设备为eth0,这就是主机路由与vxlan模式下kube-flannel的主要区别

下面我们通过一个例子来介绍一下上面新增的这些记录的实际用途,假设:

  • pod1运行在节点node1上,pod1的IP为10.244.1.3
  • pod2运行在节点node3,pod2的IP为10.244.3.3

来看一下在vxlan模式下从pod1发送数据包到pod2的详细流程;

发送

  1. 数据包从pod1出来,到达node1的协议栈,node1发现目标地址并非本机地址,且本机开启了流量转发功能,于是查找路由并转发;
  2. 目标IP为10.244.3.3,主机路由匹配到应该走flannel.1设备,下一跳为10.244.3.0(上面的node3新增时,步骤一添加的路由表项就用上了)
  3. 数据包到达flannel.1设备,它会先查找下一跳IP10.244.3.0的mac地址,在arp表中找到了匹配的记录为02:3f:39:67:7d:f9(上面节点新增时,步骤二添加的ARP记录在这里就用上了),然后完成mac头封装,准备发送。
  4. 因为是vxlan设备,发送方法与普通的网卡有些区别(详见下面的代码vxlan_xmit),数据包没有被提交到网卡的发送队列,而是由vxlan设备进一步封装成一个udp数据包,它会根据目标mac地址来反查下一跳的主机地址以决定把这个udp数据包发给哪个主机,这时候就会用到上面提到的fdb表了,它查到去往02:3f:39:67:7d:f9的下一跳主机地址为192.168.3.10(节点新增时,步骤三添加的FDB记录就用上了),于是封装udp包,走ip_local_out,发往node3 。
// linux-4.18\drivers\net\vxlan.c
static netdev_tx_t vxlan_xmit(struct sk_buff *skb, struct net_device *dev)
{
    ...
    //取链路层头部
    eth = eth_hdr(skb);
    // 根据目标mac地址查找fdb表项
    f = vxlan_find_mac(vxlan, eth->h_dest, vni);
    ...
    vxlan_xmit_one(skb, dev, vni, fdst, did_rsc);
}


static void vxlan_xmit_one(struct sk_buff *skb, struct net_device *dev,
               __be32 default_vni, struct vxlan_rdst *rdst,bool did_rsc)
{
...
        // 封装vxlan头
        err = vxlan_build_skb(skb, ndst, sizeof(struct iphdr),
                      vni, md, flags, udp_sum);
        if (err < 0)
            goto tx_error;

        // 封装UDP头、外部IP头,最后走ip_local_out
        udp_tunnel_xmit_skb(rt, sock4->sock->sk, skb, local_ip.sin.sin_addr.s_addr,
                    dst->sin.sin_addr.s_addr, tos, ttl, df,
                    src_port, dst_port, xnet, !udp_sum);
...

}

接收

  1. node3接收到udp包后,走主机协议栈,发现目标地址为本机,于是走INPUT方向,最终发到UDP层处理。
  2. 当我们创建vxlan设备时,vxlan的设备驱动会注册一个UDP的socket,端口默认为4789,然后为这个udp的socket的接收流程注册一个vxlan的接收函数;当linux协议栈的收包流程走到udp_rcv时,会调用vxlan_rcv处理,vxlan_rcv做的事情就是剥去vxlan头,将内部的一个完整的二层包重新送入主机协议栈(见下面的源码)。
  3. 剥去vxlan头部后的包重新来到主机协议栈,此时包的目标地址是10.244.3.3,经过路由判决时,发现不是本机地址,走转发,找到合适的路由,最终发往pod2。
// linux-4.18\drivers\net\vxlan.c
//创建vxlan设备时,会调用vxlan_open -> vxlan_sock_add -> __vxlan_sock_add -> vxlan_socket_create,最终会在这个方法中创建一个udp的socket,然后把vxlan的收包函数注册进来
static struct vxlan_sock *vxlan_socket_create(struct net *net, bool ipv6,
                          __be16 port, u32 flags)
{
...
    tunnel_cfg.encap_rcv = vxlan_rcv;   //这是最关键的点,收包的时候,会把vxlan的包给vxlan_rcv处理
...
}

int udp_rcv(struct sk_buff *skb)
{
	return __udp4_lib_rcv(skb, &udp_table, IPPROTO_UDP);
}

//udp包接收方法,从udp_rcv -> __udp4_lib_rcv -> udp_queue_rcv_skb,在这里,如果是vxlan设备创建的端口收的包,会给vxlan_rcv处理
static int udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
...
		/* if we're overly short, let UDP handle it */
		encap_rcv = READ_ONCE(up->encap_rcv);
		if (encap_rcv) {
			int ret;

			/* Verify checksum before giving to encap */
			if (udp_lib_checksum_complete(skb))
				goto csum_error;

			ret = encap_rcv(sk, skb);  //这里就会走到vxlan_rcv函数去
			if (ret <= 0) {
				__UDP_INC_STATS(sock_net(sk),
						UDP_MIB_INDATAGRAMS,
						is_udplite);
				return -ret;
			}
		}
...
}

/* Callback from net/ipv4/udp.c to receive packets */
static int vxlan_rcv(struct sock *sk, struct sk_buff *skb)
{
    //剥vxlan头    
    if (__iptunnel_pull_header(skb, VXLAN_HLEN, protocol, raw_proto,
                   !net_eq(vxlan->net, dev_net(vxlan->dev))))
            goto drop;
     ...
     gro_cells_receive(&vxlan->gro_cells, skb);
     ...
}

int gro_cells_receive(struct gro_cells *gcells, struct sk_buff *skb)
{
    ...
    if (!gcells->cells || skb_cloned(skb) || netif_elide_gro(dev))
        //非NAPI收包处理,linux虚拟网络设备接收如果需要软中断触发通常会走这里
        return netif_rx(skb);
    ...
}

udp模式

在udp模式下,每个节点还运行了一个叫flanneld的守护进程,这个守护进程并非以容器的方式运行,而且是实实在在地参与数据包的转发工作,这个守护进程要是挂了,通信就会中断;

这个守护进程会:

  • 开启一个unix domain socket服务,接受来自kube-flannel同步的路由信息;
  • 打开/dev/net/tun文件;
  • 打开一个udp端口并监听(默认是8285)
  • 并且总会把udp端口收到的数据写到tun文件,从tun文件读到的数据,通过udp发出去;

每个节点同样存在一个名叫flannel.1的虚拟设备,只不过不是vxlan设备,而是一个tun设备,tun设备的工作原理是:用户态程序打开/dev/net/tun文件,主机就会多一张名为tun0的网卡,任何时候往这个打开的文件写的内容都会直接被内核协议栈收包,效果就是相当于上面代码中调用了netif_rx(skb)的效果,而发往这个tun0网卡的数据,都会被打开/dev/net/tun文件的用户程序读到,读到的内容包含IP包头及以上的全部内容(如果想读到链路层的帧头,这里就应该打开一个tap设备,tun/tap的主要区别就在这);

udp模式下,kube-flannel也不再写fdb表和邻居表,而是通过unix domain socket 与本节点的flanneld守护进程通信,把从etcd订阅到的路由信息同步给flanneld。

我们继续用上面的场景举例,说明一下udp模式下的数据包发送流程:

  • pod1发送给pod2的数据给会被主机路由引导通过tun设备(flannel.1)发送;
  • flanneld进程从打开的/dev/net/tun文件收到来自pod1的数据包,目标地址是10.244.3.3,于是它要查找去往这个目标的下一跳是哪里,这个信息kube-flannel已同步,kube-flannel通过etcd可以获取到每一个pod在哪个节点中,并把pod和节点的IP的映射关系同步给flanneld;
  • 它知道下一跳是node3后,就把从tun设备收到的包作为payload向node3的flanneld(端口8285)发送udp包,跟vxlan的封包的区别就是这里是没有链路层包头的相关信息的(上面说了,tun只能拿到三层及以上)
  • node3运行的flanneld守护进程会收到这个来自node1的包,然后把payload向打开的/dev/net/tun文件写,根据tun设备的工作原理,它的另一端flannel.1网卡会收到这个包,然后就通过主机协议栈转发到pod2。

flanneld是由c语言直接实现的,关键代码在/backend/udp/proxy_adm64.c

//tun网卡的包通过udp发给对端
static int tun_to_udp(int tun, int sock, char *buf, size_t buflen) {
  struct iphdr *iph;
  struct sockaddr_in *next_hop;
  ssize_t pktlen = tun_recv_packet(tun, buf, buflen);
  if( pktlen < 0 )
    return 0;
  iph = (struct iphdr *)buf;
  next_hop = find_route((in_addr_t) iph->daddr);
  if( !next_hop ) {
    send_net_unreachable(tun, buf);
    goto _active;
  }

  if( !decrement_ttl(iph) ) {
    /* TTL went to 0, discard.
     * TODO: send back ICMP Time Exceeded
     */
    goto _active;
  }
  sock_send_packet(sock, buf, pktlen, next_hop);
_active:
  return 1;
}

//从对端收到的包写到tun网卡
static int udp_to_tun(int sock, int tun, char *buf, size_t buflen) {
  struct iphdr *iph;
  ssize_t pktlen = sock_recv_packet(sock, buf, buflen);
  if( pktlen < 0 )
    return 0;
  iph = (struct iphdr *)buf;
  if( !decrement_ttl(iph) ) {
    /* TTL went to 0, discard.
     * TODO: send back ICMP Time Exceeded
     */
    goto _active;
  }
  tun_send_packet(tun, buf, pktlen);
_active:
  return 1;
}

udp模式中守护进程flanneld发挥的作用与vxlan设备很接近,都是在封包拆包,只不过vxlan封包拆包全程在内核态完成,而udp模式会经过4次用户与内核态切换,性能就下降了,而且udp模式下,flanneld挂了,通信就会中断;

0.9.0之前的版本

特别介绍一下flannel在0.9.0版本之前,用的策略完全不一样:

  • kube-flannel不会在新增节点的时候就增加arp表和fdb表,而是在数据包传递的过程中,需要目标ip的mac地址但没有找到时会发送一个l3miss的消息(RTM_GETNEIGH)给用户态的进程,让用户进程补充邻居表信息;
  • 在封装udp包时,在fdb表找不到mac地址对应的fdb表项时,会发送一个l2miss消息给用户态进程,让用户态的进程补充fdb表项,让流程接着往下走。

它启动时会打开下面的标志位:

echo 3 > /proc/sys/net/ipv4/neigh/flannel.1/app_solicit

这样vxlan在封包过程中如果缺少arp记录和fdb记录就会往用户进程发送消息

从0.9.0版本开始,flannel取消了监听netlink消息:

https://github.com/coreos/flannel/releases/tag/v0.9.0

总结

可以看出,从0.9.0版本后的flannel在vxlan模式下,容器的通信完全由linux内核完成,已经不用kube-flannel参与了,这就意味着,哪怕在运行的过程中,kube-flannel挂掉了,也不会影响现有容器的通信,只会影响新加入的节点和新创建的容器。

了解了flannel的原理后,接下来我们照着撸一个cni吧。

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 深入理解kubernetes(k8s)网络原理之二-service原理

    上一篇文章中我们介绍了pod与主机互通及pod访问外网的原理,在这一章我们将介绍pod与pod的相互访问以及外部服务访问pod的方式,由此引出k8s的servi...

    一生无聊
  • 深入理解kubernetes(k8s)网络原理之四-pod流量控制

    在前面的几篇文章中,我们解决了pod连接主机、pod连接外网、pod与相同节点或不同节点的pod连接、用clusterIP和nodeport的方式访问pod等几...

    一生无聊
  • 深入理解kubernetes(k8s)网络原理之一-pod连接主机

    对于刚接触k8s的人来说,最令人懵逼的应该就是k8s的网络了,如何访问部署在k8s的应用,service的几种类型有什么区别,各有什么使用场景,服务的负载均衡是...

    一生无聊
  • 深入理解kubernetes(k8s)网络原理之三-跨主机pod连接

    在之前的两篇文章中分别介绍了pod与主机连接并且上外网的原理及service的clusterIP和nodeport的实现原理,对于组织pod的网络这件事来说,还...

    一生无聊
  • 跨VPC或者跨云供应商搭建K8S集群正确姿势-番外篇

    上周发了几篇关于Kubernetes集群搭建相关的文章,里面有一个部分谈到了Kubernetes集群CNI插件(也就是容器网络接口)的部署,很多读者看到了这个部...

    云爬虫技术研究笔记
  • Kubernetes动手系列:手把手教你10分钟快速部署集群

    Kubernetes 动手系列想通过一系列动手的 demo ,来帮助读者快速的理解上手 Kubernetes 一些运行机制。会包括如下内容:

    CNCF
  • 2.2 Kubernetes--网络通讯

      k8s的网络模型假定了所有的Pod都在一个可以直接连通的扁平的网络空间中, 这在GCE(Google Compute Engine)里面是线程的网络模型, ...

    用户7798898
  • 从零开始入门 K8s | Kubernetes 网络概念及策略控制

    本文来介绍一下 Kubernetes 对网络模型的一些想法。大家知道 Kubernetes 对于网络具体实现方案,没有什么限制,也没有给出特别好的参考案例。Ku...

    CNCF
  • 用Kubernetes部署超级账本Fabric的区块链即服务(1)

    【注:下载本文PDF版本以及本文源代码,可关注本公众号:亨利笔记,后台发送消息“区块链即服务” 或 “baas”即可。】

    Henry Zhang
  • 跨VPC或者跨云供应商搭建K8S集群正确姿势-番外篇

    上周发了几篇关于Kubernetes集群搭建相关的文章,里面有一个部分谈到了Kubernetes集群CNI插件(也就是容器网络接口)的部署,很多读者看到了这个部...

    我的小碗汤
  • .Net微服务实战之Kubernetes的搭建与使用

      说到微服务就得扯到自动化运维,然后别人就不得不问你用没用上K8S。K8S的门槛比Docker Compose、Docker Swarm高了不少,无论是概念上...

    陈珙
  • kubernetes集群网络

    问题:Pod是K8S最小调度单元,一个Pod由一个容器或多个容器组成,当多个容器时,怎么都用这一个Pod IP?

    yuezhimi
  • kubernetes从懵圈到熟练 – 集群网络详解

    阿里云K8S集群网络目前有两种方案,一种是flannel方案,另外一种是基于calico和弹性网卡eni的terway方案。Terway和flannel类似,不...

    kubernetes中文社区
  • 042.集群网络-flannel及calico

    Kubernetes的网络模型假定了所有Pod都在一个可以直接连通的扁平网络空间中。若需要实现这个网络假设,需要实现不同节点上的Docker容器之间的互相访问,...

    木二
  • ASP.NET Core on K8S深入学习(11)K8S网络知多少

    本篇已加入《.NET Core on K8S学习实践系列文章索引》,可以点击查看更多容器化技术相关系列文章。

    Edison Zhou
  • rancher-2:rancher2.5.5部署的单节点kubernetes集群下的pod与容器探究

    rancher-1:使用rancher-2.5.5部署单节点kubernetes集群

    千里行走
  • Kubernetes集群部署关键知识总结

      Kubernetes集群部署需要安装的组件东西很多,过程复杂,对服务器环境要求很苛刻,最好是能连外网的环境下安装,有些组件还需要连google服务器下载,这...

    欢醉
  • Web基础配置篇(十六): Kubernetes集群的安装使用

    Kubernetes 简称为K8S,是用于自动部署,扩展和管理容器化应用程序的开源系统。Kubernetes的目标是让部署容器化的应用简单并且高效(powerf...

    品茗IT
  • 国内环境Kubernetes v1.12.1的安装与配置

    版权声明:本文为耕耘实录原创文章,各大自媒体平台同步更新。欢迎转载,转载请注明出处,谢谢

    耕耘实录

扫码关注云+社区

领取腾讯云代金券