2025年1月

1. 信道是golang中的顶级公民

goroutine结合信道channel是golang中实现并发编程的标配。

信道给出了一种不同于
传统共享内存并发通信
的新思路,以一种
通道复制
的思想解耦了并发编程的各个参与方。

信道分为两种: 无缓冲和有缓冲信道(先入先出)。

分别用于goroutine同步和异步生产消费:

无缓冲信道: 若没有反向的goroutine在做动作, 当前goroutine会阻塞;
有缓冲信道: goroutine 直接面对的是缓冲队列, 队列满则写阻塞, 队列空则读阻塞。

一个陷阱: 信道被关闭后, 原来的goroutine阻塞状态不会维系, 能从信道读取到零值。

for range可以用于信道 :
一直从指定信道中

值, 没有数据会阻塞, 直到信道关闭会自动退出循环。

var ch chan int = make(chan int, 10)
go func() {
	for i := 0; i < 20; i++ { 
		ch <- i
	}
	close(ch)
}()

time.Sleep(time.Second * 2)
for ele := range ch {
	fmt.Println(ele)
}

output: 0,1,2,3,4...19

上面的示例描述了信道4个阶段:
写完10个数据(阻塞写)、暂停2s、
读取10个数据(解除阻塞写)、读完20个数据、关闭信道。

2. 信道channel实现思路大盘点

channel是指向
hchan
结构体的指针.

        type hchan struct {
        	qcount   uint           // 队列中已有的缓存元素的数量
        	dataqsiz uint           // 环形队列的容量
        	buf      unsafe.Pointer // 环形队列的地址
        	elemsize uint16
        	closed   uint32        // 标记是否关闭,初始化为0,一旦close(ch)为1
        	elemtype *_type // 元素类型
        	sendx    uint   // 待发送的元素索引
        	recvx    uint   // 待接受元素索引
        	recvq    waitq  // 阻塞等待的读goroutine队列
        	sendq    waitq  // 阻塞等待的写gotoutine队列
         
        	// lock protects all fields in hchan, as well as several
        	// fields in sudogs blocked on this channel.
        	//
        	// Do not change another G's status while holding this lock
        	// (in particular, do not ready a G), as this can deadlock
        	// with stack shrinking.
        	lock mutex
        }
        
    type waitq struct {  
        first *sudog  
        last *sudog  
    }

2.1 静态全局解读

两个核心的结构

① 环形队列
buf
(buf、dataqsize、sendx、recvx 圈定了一个有固定长度,由读/写指针控制队列数据的环形队列),从这看出队列是以链表实现。

② 存放阻塞写G和阻塞读G的队列
sendq

recvq
,
recvq、sendq存放的不是当前通信的goroutine
, 而是
因读写信道而阻塞的goroutine
:

  • 如果 qcount <dataqsiz(队列未满),sendq就为空(写就不会阻塞);
  • 如果 qcount > 0 (队列不为空),recvq就为空(读就不会阻塞)。

一旦解除阻塞,读/写动作会给到先进入阻塞队列的goroutine,也就是 recvq、sendq也是先进先出。

2.2 动态解读demo

以第一部分的demo为例:

第一阶段: 写入0到9这个10个元素

  1. goroutine在写数据之后会获取锁,以确保安全地修改信道底层的
    hchan
    结构体;
  2. 向环形队列
    buf
    入队enqueue元素,实际是将原始数据拷贝进环形队列
    buf
    的待插入位置
    sendx
  3. 入队操作完成,释放锁。

第二阶段:信道满,写阻塞(写goroutine会停止,并等待读操作唤醒)

① 基于
写goroutine
创建
sudog
, 并将其放进
sendq队列
中;

② 调用
gopark
函数,让调度器P终止该goroutine执行。

调度器P将该goroutine状态改为
waiting
, 并从调度器P挂载的
runQueue
中移除,调度器P重新出队一个G交给OS线程来执行,这就是上下文切换,G被阻塞了而不是OS线程。


读goroutine
开始被调度执行:

第三阶段: 读前10个元素(解除写阻塞)

  1. for range chan: 读goroutine从
    buf
    中出队元素: 将信道元素拷贝到目标接收区;
  2. 写goroutine从
    sendq
    中出队,因为现在信道不满,写不会阻塞;
  3. 调度器P调用
    goready
    , 将写goroutine状态变为
    runnable
    ,并移入runQueue。

下面的源码截取自
chansend()

体现了写信道--> 写阻塞---> 被唤醒的过程

     // 这一部分是写数据, 从这里也可以看出是点对点的覆写,原buf内队列元素不用移动, 只用关注sendx  
     
        if c.qcount < c.dataqsiz {  // 信道未满,则写不会阻塞=>senq为空	
                qp := chanbuf(c, c.sendx)   // chanbuf(c, i) 返回的是信道buf中待插入的位置指针
                typedmemmove(c.elemtype, qp, ep)  
                c.sendx++
                if c.sendx == c.dataqsiz {
                     c.sendx = 0
                }
                c.qcount++
                return true
        }
        if !block {       // 用于select case结构中,不阻塞select case的选择逻辑
                unlock(&c.lock)
                return false
        }

  // 这二部分是: 构建sudog,放进写阻塞队列,阻塞当前写gooroutine的执行
        // Block on the channel. Some receiver will complete our operation for us.
        gp := getg()     // 获取当前的goroutine  https://go.dev/src/runtime/HACKING
        mysg := acquireSudog()   // sudog是等待队列sendq中的元素,封装了goroutine
        mysg.releasetime = 0
        if t0 != 0 {
                mysg.releasetime = -1
        }
        // No stack splits between assigning elem and enqueuing mysg
        // on gp.waiting where copystack can find it.
        mysg.elem = ep
        mysg.waitlink = nil
        mysg.g = gp
        mysg.isSelect = false
        mysg.c = c
        gp.waiting = mysg
        gp.param = nil
        c.sendq.enqueue(mysg)  // 当前goroutine压栈sendq
        // Signal to anyone trying to shrink our stack that we're about
        // to park on a channel. The window between when this G's status
        // changes and when we set gp.activeStackChans is not safe for
        // stack shrinking.
        gp.parkingOnChan.Store(true)
        reason := waitReasonChanSend

        gopark(chanparkcommit, unsafe.Pointer(&c.lock), reason, traceBlockChanSend, 2)   // 这里是阻塞函数
    	
        KeepAlive(ep)
 // 这三部分: 调度器唤醒了当前goroutine
        // someone woke us up.  
        if mysg != gp.waiting {
                throw("G waiting list is corrupted")
        }
        gp.waiting = nil
        gp.activeStackChans = false
        closed := !mysg.success
        gp.param = nil
        if mysg.releasetime > 0 {
                blockevent(mysg.releasetime-t0, 2)
        }
        mysg.c = nil
        releaseSudog(mysg)
        if closed {     // 已经关闭了,再写数据会panic
             if c.closed == 0 {
                 throw("chansend: spurious wakeup")
             }
            panic(plainError("send on closed channel"))
        }
        return true

其中:


getg
获取当前的goroutine,sudog是goroutine的封装,表征一个因读写信道而阻塞的G,


typedmemmove(c.elemtype, qp, ep)
: 写数据到信道buf,由两个指针来完成拷贝覆写。

  //  typedmemmove copies a value of type typ to dst from src.
    func typedmemmove(typ *abi.Type, dst, src unsafe.Pointer) {
    	if dst == src {
    		return
    	}
    	if writeBarrier.enabled && typ.Pointers() {
    		// This always copies a full value of type typ so it's safe
    		// to pass typ along as an optimization. See the comment on
    		// bulkBarrierPreWrite.
    		bulkBarrierPreWrite(uintptr(dst), uintptr(src), typ.PtrBytes, typ)
    	}
    	// There's a race here: if some other goroutine can write to
    	// src, it may change some pointer in src after we've
    	// performed the write barrier but before we perform the
    	// memory copy. This safe because the write performed by that
    	// other goroutine must also be accompanied by a write
    	// barrier, so at worst we've unnecessarily greyed the old
    	// pointer that was in src.
    	memmove(dst, src, typ.Size_)
    	if goexperiment.CgoCheck2 {
    		cgoCheckMemmove2(typ, dst, src, 0, typ.Size_)
    	}
    }

③ 我们看上面源码的第三部分, 唤醒了阻塞的
写goroutine
, 但是这里貌似没有将写goroutine携带的值传递给信道或对端。
实际上这个行为是在
recv
函数内。

跟一下接收方:读第一个元素,刚解除写阻塞的源码:

// 发现sendq有阻塞的写G,则读取,并使用该写G携带的数据填充数据
// Just found waiting sender with not closed.
    if sg := c.sendq.dequeue(); sg != nil {
    // Found a waiting sender. If buffer is size 0, receive value
    // directly from sender. Otherwise, receive from head of queue
    // and add sender's value to the tail of the queue (both map to
    // the same buffer slot because the queue is full).
    recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
    return true, true
}
if c.qcount > 0 {  // 如果sendq队里没有阻塞G, 则直接从队列中读值
    // Receive directly from queue
}

---

{
    // Queue is full. Take the item at the
    // head of the queue. Make the sender enqueue
    // its item at the tail of the queue. Since the
    // queue is full, those are both the same slot.
    qp := chanbuf(c, c.recvx)  // 拿到buf中待接受元素指针
    if raceenabled {
            racenotify(c, c.recvx, nil)
            racenotify(c, c.recvx, sg)
    }
    // copy data from queue to receiver
    if ep != nil {
            typedmemmove(c.elemtype, ep, qp)  // 将buf中待接收元素qp拷贝到目标指针ep
    }
    // copy data from sender to queue
    typedmemmove(c.elemtype, qp, sg.elem)  //  将阻塞sendq队列中出站的sudog携带的值写入到待插入指针。
    c.recvx++
    if c.recvx == c.dataqsiz {
            c.recvx = 0
    }
    c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
}
        

从上线源码可以验证:


读goroutine
读取第一个元素之前,信道满,此时sendx=recvx,也即信道内读写指针指向同一个槽位;

② 读取第一个元素,解除写阻塞:
sendq
写G队列会出队第一个sudog, 将其携带的元素填充进
buf
待插入指针
sendx
,因为此时sendx=recvx,故第二次
typedmemmove(c.elemtype, qp, sg.elem)
是合理的。

如果sendq队列没有阻塞G, 则直接从buf中读取值。

3. 不要使用共享内存来通信,而是使用通信来共享内存

常见的后端java C#标配使用共享内存来通信, 比如 mutex、lock 关键词:
通过对一块共有的区域做属性变更来反映系统当前的状态,详细的请搜索
同步索引块

golang 推荐使用通信来共享内存, 这个是怎么理解的呢?

你要想使用某块内存数据, 并不是直接共享给你, 而是给你一个信道作为访问的接口, 并且你得到的是目标数据的拷贝,由此形成的信道访问为通信方式;

而原始的目标数据的生命周期由产生这个数据的G来决定, 它甚至不用care自己是不是要被其他G获知,因此体现了解耦并发编程参与方的作用。

https://medium.com/womenintechnology/exploring-the-internals-of-channels-in-go-f01ac6e884dc

4. 信道的实践指南

4.1 无缓冲信道

结合了通信(值交换)和同步。

    c := make(chan int)  // Allocate a channel.
    // Start the sort in a goroutine; when it completes, signal on the channel.
    go func() {
        list.Sort()
        c <- 1  // Send a signal; value does not matter.
    }()
    doSomethingForAWhile()
    <-c   // Wait for sort to finish; discard sent value.

4.2 有缓冲信道

基础实践: 信号量、限流能力

下面演示了:服务端使用有缓冲信道限制并发请求

var sem = make(chan int, MaxOutstanding) 

func Serve(queue chan *Request) {
    for req := range queue {
        req:= req
        sem <- 1   
        go func() {   // 只会开启MaxOutstanding个并发协程
            process(req)
            <-sem
        }()
    }
}

上面出现了两个信道:

sem
提供了限制服务端并发处理请求的信号量

queue
提供了一个客户端请求队列,起媒介/解耦的作用

解多路复用

多路复用是网络编程中一个耳熟能详的概念,nginx redis等高性能web、内存kv都用到了这个技术 。

这个
解多路复用
是怎么理解呢?

我们针对上面的服务端,编写客户端请求, 独立的客户端请求被服务端Serve收敛之后, Serve就起到了多路复用的概念,在
Request
定义
resultChan信道
,就给每个客户端请求提供了独立获取请求结果的能力, 这便是一种解多路复用。

    type Request struct {
        args        []int
        f           func([]int) int
        resultChan  chan int
    }
    request := &Request{[]int{3, 4, 5}, nil, make(chan int)}

    func SendReq(req *Request){
        // Send request
        clientRequests <- request
        // Wait for response.
        fmt.Printf("answer: %d\n", <-request.resultChan)
    }

在服务端,定义handler,返回响应结果

    // 定义在服务端的处理handler
    func sum(a []int) (s int) {
        for _, v := range a {
            s += v
        }
        return
    }

    func process(req *Request) {
       req.f = sum
       req.resultChan <- req.f(req.args)
    }

基于cpu的并行编程

如果计算可被划分为独立的(不相互依赖的)计算分片,则可以利用信道开启CPU的并行编程能力。

    var numCPU = runtime.NumCPU() // number of CPU cores

    func (v Vector) DoAll(u Vector) {
        c := make(chan int, numCPU)  // Buffering optional but sensible.
        for i := 0; i < numCPU; i++ {
            go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
        }
        
        for i := 0; i < numCPU; i++ {
            <-c    // wait for one task to complete
        }
        // All done.
    }

全文复盘

本文整体视角讲述了Golang信道的用法、信道的静态结构(通过这个静态结构读者可以盲猜一二);
通过一个动态示例(G和信道的交互行为)解剖了信道在阻塞前后的状态变化;

最后给出了信道的常规实践, 解读了一些常规姿势的上层思路来源。

node-local-dns.png

本文主要分享如何使用 NodeLocal DNSCache 来提升集群中的 DNS 性能以及可靠性,包括部署、使用配置以及原理分析,最终通过压测表明使用后带来了高达 50% 的性能提升。

1.背景

什么是 NodeLocalDNS

NodeLocal DNSCache
是一套 DNS 本地缓存解决方案。
NodeLocal DNSCache 通过在集群节点上运行一个 DaemonSet 来提高集群 DNS 性能和可靠性

为什么需要 NodeLocalDNS

处于 ClusterFirst 的 DNS 模式下的 Pod 可以连接到 kube-dns 的 serviceIP 进行 DNS 查询,通过 kube-proxy 组件添加的 iptables 规则将其转换为 CoreDNS 端点,最终请求到 CoreDNS Pod。

通过在每个集群节点上运行 DNS 缓存,
NodeLocal DNSCache
可以缩短 DNS 查找的延迟时间、使 DNS 查找时间更加一致,以及减少发送到 kube-dns 的 DNS 查询次数。

在集群中运行
NodeLocal DNSCache
有如下几个好处:

  • 如果本地没有 CoreDNS 实例,则具有最高 DNS QPS 的 Pod 可能必须到另一个节点进行解析,使用
    NodeLocal DNSCache
    后,拥有本地缓存将有助于改善延迟
  • 跳过 iptables DNAT 和连接跟踪将有助于减少 conntrack 竞争并避免 UDP DNS 条目填满 conntrack 表(上面提到的 5s 超时问题就是这个原因造成的)
  • 从本地缓存代理到 kube-dns 服务的连接可以升级到 TCP,TCP conntrack 条目将在连接关闭时被删除,而 UDP 条目必须超时(默认
    nfconntrackudp_timeout
    是 30 秒)
  • 将 DNS 查询从 UDP 升级到 TCP 将减少归因于丢弃的 UDP 数据包和 DNS 超时的尾部等待时间,通常长达 30 秒(3 次重试+ 10 秒超时)

node-local-dns-flow.png

2. 如何使用 NodeLocalDNS

NodeLocalDNS 部署

要安装
NodeLocal DNSCache
也非常简单,直接获取官方的资源清单即可:

wget -c https://raw.githubusercontent.com/kubernetes/kubernetes/master/cluster/addons/dns/nodelocaldns/nodelocaldns.yaml

默认使用的镜像为
registry.k8s.io/dns/k8s-dns-node-cache
如果无法拉取镜像,可以替换成国内的
docker.io/dyrnq/k8s-dns-node-cache

cp nodelocaldns.yaml nodelocaldns.yaml.bak

sed -i 's#registry\.k8s\.io/dns/k8s-dns-node-cache#docker\.io/dyrnq/k8s-dns-node-cache#g' nodelocaldns.yaml

该资源清单文件中包含几个变量,各自含义如下:

  • __PILLAR__DNS__DOMAIN__
    :表示集群域,默认为
    cluster.local
    ,它是用于解析 Kubernetes 集群内部服务的域名后缀。
  • __PILLAR__LOCAL__DNS__
    :表示 DNSCache 本地的 IP,也就是 NodeLocalDNS 要使用的 IP,默认为 169.254.20.10
  • _
    _PILLAR__DNS__SERVER__
    :表示 kube-dns 这个 Service 的 ClusterIP,一般默认为 10.96.0.10。通过
    kubectl get svc -n kube-system -l k8s-app=kube-dns -o jsonpath='{$.items[*].spec.clusterIP}'
    命令获取

下面两个变量则不需要关系,NodeLocalNDS Pod 会自动配置,对应的值来源于 kube-dns 的 ConfigMap 和定制的
Upstream Server
配置。直接执行如下所示的命令即可安装:

  • __PILLAR__CLUSTER__DNS__
    : 表示集群内查询的上游 DNS 服务器,一般也指向 kube-dns 的 service IP,默认为 10.96.0.10。
  • __PILLAR__UPSTREAM__SERVERS__
    :表示为外部查询的上游服务器,如果没有专门的自建 DNS 服务的话,也可以填 kube-dns 的 service ip。

接下来将对应变量替换为真实值,具体如下:

kubedns=`kubectl get svc kube-dns -n kube-system -o jsonpath={.spec.clusterIP}`
domain=cluster.local
localdns=169.254.20.10

echo kubedns=$kubedns, domain=$domain, localdns=$localdns

需要注意的是:
根据 kube-proxy 运行模式不同,要替换的参数也不同
,使用以下命令查看 kube-proxy 所在模式

kubectl -n kube-system get cm kube-proxy -oyaml|grep mode

如果kube-proxy在
iptables
模式下运行, 则运行以下命令创建

cp nodelocaldns.yaml nodelocaldns-iptables.yaml
sed -i "s/__PILLAR__LOCAL__DNS__/$localdns/g; 
        s/__PILLAR__DNS__DOMAIN__/$domain/g; 
        s/__PILLAR__DNS__SERVER__/$kubedns/g" nodelocaldns-iptables.yaml

node-local-dns Pod 会设置
PILLAR__CLUSTER__DNS

PILLAR__UPSTREAM__SERVERS

如果 kube-proxy 在
ipvs
模式下运行, 则运行以下命令创建

cp nodelocaldns.yaml nodelocaldns-ipvs.yaml

sed -i "s/__PILLAR__LOCAL__DNS__/$localdns/g; 
        s/__PILLAR__DNS__DOMAIN__/$domain/g; 
        s/,__PILLAR__DNS__SERVER__//g; 
        s/__PILLAR__CLUSTER__DNS__/$kubedns/g" nodelocaldns-ipvs.yaml

node-local-dns Pod 会设置
PILLAR__UPSTREAM__SERVERS

然后就是将替换后的 yaml apply 到集群里:

#kubectl apply -f nodelocaldns-iptables.yaml
kubectl apply -f nodelocaldns-ipvs.yaml

会创建以下对象

serviceaccount/node-local-dns created
service/kube-dns-upstream created
configmap/node-local-dns created
daemonset.apps/node-local-dns created
service/node-local-dns created

创建完成后,就能看到每个节点上都运行了一个pod,这里只有一个节点,所以就运行了一个

[root@caas ~]# kubectl -n kube-system get po
NAME                         READY   STATUS    RESTARTS   AGE
node-local-dns-m8ktq         1/1     Running     0         8s

需要注意的是这里使用 DaemonSet 部署 node-local-dns 使用了 hostNetwork=true,会占用宿主机的
8080
端口,所以需要保证该端口未被占用。

NodeLocalDNS 配置

上一步部署好
NodeLocal DNSCache,
但是还差了很重要的一步,
配置
pod
使用 NodeLocal DNSCache 作为优先的
DNS
服务器。

有以下几种方式:

  • 方式一:修改 kubelet 中的 dns nameserver 参数,并重启节点 kubelet。
    存在业务中断风险,不推荐使用此方式

    • 测试时可以用这个方式,比较简单
  • 方式二:创建 Pod 时手动指定 DNSConfig,
    比较麻烦,不推荐。
  • 方式三:借助 DNSConfig 动态注入控制器在 Pod 创建时配置 DNSConfig 自动注入,
    推荐使用此方式。
    • 需要自己实现一个 webhook,相当于把方式二自动化了,

方式一:修改 kubelet 参数

kubelet通过
--cluster-dns

--cluster-domain
两个参数来全局控制Pod DNSConfig。

  • cluster-dns
    :部署Pod时,默认采用的DNS服务器地址,默认只引用了
    kube-dns
    的 ServiceIP,需要增加一个 NodeLocalDNS 的 169.254.20.10 。
  • cluster-domain
    :部署 Pod 时,默认采用的 DNS 搜索域,保持原有搜索域即可,一般为
    cluster.local


/etc/systemd/system/kubelet.service.d/10-kubeadm.conf
配置文件中需要
增加
一个
--cluster-dns
参数,设置值为NodeLocalDNS 的 169.254.20.10。

注意是在原有的前面增加一个 --cluster-dns,不是把原本的改了。

这样 Pod 中就会有两个 dns nameserver,如果新增的这个失效了,也可以使用旧的。

vi /etc/systemd/system/kubelet.service.d/10-kubeadm.conf 
# 增加 --cluster-dns
--cluster-dns=169.254.20.10 --cluster-dns=<kube-dns ip> --cluster-domain=<search domain>

然后重启 kubelet 使其生效

sudo systemctl daemon-reload
sudo systemctl restart kubelet

方式二:自定义 Pod dnsConfig

通过 dnsConfig 字段自定义
Pod

dns
配置
,nameservers 中除了指定 NodeLocalDNS 之外还指定了 KubeDNS,这样即使 NodeLocalDNS 异常也不影响 Pod 中的 DNS 解析。

apiVersion: v1
kind: Pod
metadata:
  name: alpine
  namespace: default
spec:
  containers:
  - image: alpine
    command:
      - sleep
      - "10000"
    imagePullPolicy: Always
    name: alpine
  dnsPolicy: None
  dnsConfig:
    nameservers: ["169.254.20.10","10.96.0.10"]
    searches:
    - default.svc.cluster.local
    - svc.cluster.local
    - cluster.local
    options:
    - name: ndots
      value: "3"
    - name: attempts
      value: "2"
    - name: timeout 
      value: "1"
  • dnsPolicy:必须为
    None
  • nameservers:配置成 169.254.20.10 和 kube-dns 的 ServiceIP 地址。
  • searches:设置搜索域,保证集群内部域名能够被正常解析。
  • ndots:默认为 5,可以适当降低 ndots 以提升解析效率。

方式三:Webhook 自动注入 dnsConfig

DNSConfig 动态注入控制器可用于自动注入DNSConfig至新建的Pod中,避免您手工配置Pod YAML进行注入。本应用默认会监听包含
node-local-dns-injection=enabled
标签的命名空间中新建Pod的请求,您可以通过以下命令给命名空间打上Label标签。

部署后,只需要给 Namespace 打上
node-local-dns-injection=enabled
label 即可,Webhook 检测就会自动给该 Namespace 下所有 Pod 配置 DNSConfig。

先挖个坑,下一篇做一个简单实现。

3. 压测

接下来进行压测,看一下性能提升。

这里使用修改 kubelet 参数方式暂时让 Pod 都使用 NodeLocalDNS,便于测试

测试环境:

1 master 1 worker 的 k8s 集群,节点规则统一 4C8G,空闲状态,未运行其他负载。

可以参考
Kubernetes教程(十一)---使用 KubeClipper 通过一条命令快速创建 k8s 集群
快速创建一个集群。

压测脚本

使用下面这个文件进行性能测试

// main.go
package main

import (
    "context"
    "flag"
    "fmt"
    "net"
    "sync/atomic"
    "time"
)

var host string
var connections int
var duration int64
var limit int64
var timeoutCount int64

func main() {
    flag.StringVar(&host, "host", "", "Resolve host")
    flag.IntVar(&connections, "c", 100, "Connections")
    flag.Int64Var(&duration, "d", 0, "Duration(s)")
    flag.Int64Var(&limit, "l", 0, "Limit(ms)")
    flag.Parse()

    var count int64 = 0
    var errCount int64 = 0
    pool := make(chan interface{}, connections)
    exit := make(chan bool)
    var (
       min int64 = 0
       max int64 = 0
       sum int64 = 0
    )

    go func() {
       time.Sleep(time.Second * time.Duration(duration))
       exit <- true
    }()

endD:
    for {
       select {
       case pool <- nil:
          go func() {
             defer func() {
                <-pool
             }()
             resolver := &net.Resolver{}
             now := time.Now()
             _, err := resolver.LookupIPAddr(context.Background(), host)
             use := time.Since(now).Nanoseconds() / int64(time.Millisecond)
             if min == 0 || use < min {
                min = use
             }
             if use > max {
                max = use
             }
             sum += use
             if limit > 0 && use >= limit {
                timeoutCount++
             }
             atomic.AddInt64(&count, 1)
             if err != nil {
                fmt.Println(err.Error())
                atomic.AddInt64(&errCount, 1)
             }
          }()
       case <-exit:
          break endD
       }
    }
    fmt.Printf("request count:%d\nerror count:%d\n", count, errCount)
    fmt.Printf("request time:min(%dms) max(%dms) avg(%dms) timeout(%dn)\n", min, max, sum/count, timeoutCount)
}

首先配置好 golang 环境,然后直接构建上面的测试应用:

go build -o testdns main.go

构建完成后生成一个 testdns 的二进制文件

跨节点 DNS 性能测试

首先测试跨节点 DNS 性能测试,因为随着集群规模扩大,CoreDNS 副本数和节点数很明显不能做到 1:1,因此大部分 DNS 请求都是跨节点的,这个性能也更能反映正常情况下的 DNS 性能。

一般推荐是 1:8,即 8 个节点对应 1 个 CoreDNS Pod

首先将 CoreDNS 副本数调整为 1,便于测试。

kubectl -n kube-system scale deploy coredns --replicas=1

这样就是两个节点对应一个 CoreDNS Pod,就可以测试跨节点 DNS 解析性能了。

[root@dns-1 go]# kubectl get node
NAME    STATUS   ROLES           AGE   VERSION
dns-1   Ready    control-plane   48m   v1.27.4
dns-2   Ready    <none>          48m   v1.27.4
[root@dns-1 go]# kubectl -n kube-system get po -owide -l k8s-app=kube-dns
NAME                       READY   STATUS    RESTARTS   AGE   IP             NODE    NOMINATED NODE   READINESS GATES
coredns-5d78c9869d-l7vgv   1/1     Running   0          12m   172.25.173.4   dns-1   <none>           <none>

当前 CoreDNS 在 dns-1 节点,那我们把测试 Pod 指定调度到 dns-2 节点。

通过 overrides 直接指定 nodeName,让 Pod 和 CoreDNS 分散到不同节点。

kubectl run busybox3 --image=busybox:latest --restart=Never --overrides='{ "spec": { "nodeName": "dns-2" } }' -- sleep 10000

然后我们将这个二进制文件拷贝到 Pod 中去进行测试:

kubectl cp testdns busybox:/

拷贝完成后进入这个测试的 Pod 中去:

kubectl exec -it busybox -- /bin/sh

然后我们执行 testdns 程序来进行压力测试,比如执行 200 个并发,持续 30 秒:

# 对地址 kube-dns.kube-system 进行解析

/ # ./testdns -host kube-dns.kube-system -c 200 -d 30 -l 5000
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
lookup kube-dns.kube-system on 10.96.0.10:53: no such host
request count:131063
error count:23
request time:min(1ms) max(15050ms) avg(39ms) timeout(624n)

我们可以看到平均耗时为 39ms 左右,这个性能是比较差的,而且还有部分解析失败的条目。

同节点 DNS 性能测试

重新创建 busybox pod,指定调度到和 CoreDNS 同一个节点,测试同节点 DNS 解析性能。

理论上同节点性能会比跨节点提升不少

然后创建一个 Busybox Pod 用于测试,通过 overrides 直接指定 nodeName,让 Pod 和 CoreDNS 分散到不同节点。

kubectl delete pod busybox

kubectl run busybox --image=busybox:latest --restart=Never --overrides='{ "spec": { "nodeName": "dns-1" } }' -- sleep 10000

然后我们将这个二进制文件拷贝到 Pod 中去进行测试:

kubectl cp testdns busybox:/

拷贝完成后进入这个测试的 Pod 中去:

kubectl exec -it busybox -- /bin/sh

然后我们执行 testdns 程序来进行压力测试,比如执行 200 个并发,持续 30 秒:

# 对地址 kube-dns.kube-system 进行解析

/ # ./testdns -host kube-dns.kube-system -c 200 -d 30 -l 5000
request count:217030
error count:0
request time:min(1ms) max(5062ms) avg(26ms) timeout(311n)

我们可以看到大部分平均耗时都是在 26ms 左右,相比之前的 40ms,提升了接近 50%,而且也没有出现超时、失败的情况。

NodeLocalDNS 测试

直接启动 Pod

kubectl delete pod busybox
kubectl run busybox --image=busybox:latest --restart=Never -- sleep 10000

然后我们将这个二进制文件拷贝到 Pod 中去进行测试:

kubectl cp testdns busybox:/

拷贝完成后进入这个测试的 Pod 中去:

kubectl exec -it busybox -- /bin/sh

然后我们执行 testdns 程序来进行压力测试,比如执行 200 个并发,持续 30 秒:

把 Pod 中的 DNS Nameserver 指向 169.254.20.10(即 NodeLocalDNS 地址),然后再次测试

vi /etc/resolv.conf

增加以下内容

nameserver 169.254.20.10

然后再次测试

/ # ./testdns -host kube-dns.kube-system -c 200 -d 30 -l 5000
request count:224103
error count:0
request time:min(1ms) max(5057ms) avg(24ms) timeout(333n)

可以看到,平均耗时都是 24ms,比跨节点的 39ms 提升 50%,和同节点的 26ms 接近,这样说明跨节点 DNS 解析有大量性能损失。


NodeLocalDNS 和同节点对比依旧存在一些提升
,因为:

  • 访问 CoreDNS 使用的是 service 的 clusterIP 10.96.0.10 最终会进过 iptables / ipvs 等规则转发到后端 CoreDNS Pod 中
  • 而访问 NodeLocalDNS 则是使用的 link-local ip 169.254.20.10,不会经过 iptables / ipvs 规则跳转,直接就会进入 NodeLocalDNS Pod。

因此,有略微的性能提升。

4.NodeLocal DNSCache 工作原理

这部分主要分析 NodeLocal DNSCache 工作原理。

工作原理分析

NodeLocalDNS 实际就是在每个节点上加了一个缓存,
类似于
CDN
,把 中心 CoreDNS 看做源站的话,node-local-dns 就是运行在不同区域的缓存。

Pod 优先从本地 NodeLocalDNS 做 DNS 解析,有数据则直接返回,否则 NodeLocalDNS 再找 KubeDNS 解析,然后本地把数据缓存下来。

具体流程正如
阿里云文档
) 中的这个图所示:

node-local-dns-flow2.png

首先控制面,创建 Pod 时 Admission Webhook 会自动注入 DNSConfig,已经注入 DNSConfig 和 未注入 DNSConfig 的 Pod 会拥有不同的情况。

具体如下:

1)已注入 DNS 本地缓存的Pod,默认会通过 NodeLocal DNSCache 监听于节点上的IP(169.254.20.10)解析域名。

Pod 内的 DNS 配置如下:

/ # cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 169.254.20.10
nameserver 10.96.0.10
options ndots:5

169.254.20.10 为第一个 nameserver,因此会优先使用。

2)NodeLocal DNSCache 本地若无缓存应答解析请求,则会通过 kube-dns 服务请求 CoreDNS 进行解析。

NodeLocalDNS 的 Corefile 中相关配置如下:

    .:53 {
        errors
        cache 30
        reload
        loop
        bind 169.254.20.10 __PILLAR__DNS__SERVER__
        forward . __PILLAR__UPSTREAM__SERVERS__
        prometheus :9253
        }

当无法解析时,会转发到上游服务,也就是 kube-dns。

3)已注入 DNS 本地缓存的 Pod,当无法连通 NodeLocal DNSCache 时,会继而直接通过 kube-dns 服务连接到CoreDNS 进行解析,此链路为备用链路。

Pod 中的 DNS 配置:

/ # cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 169.254.20.10
nameserver 10.96.0.10
options ndots:5

Kube-dns 对应的 IP 10.96.0.10 也做为第二 nameserver ,因此NodeLocal DNS 异常时 Pod 也能正常进行 DNS 解析。

4)未注入 DNS本地缓存的 Pod,会通过标准的 kube-dns 服务链路连接到 CoreDNS 进行解析。

未注入 DNSConfig 的 Pod 默认 DNS 配置如下:

/ # cat /etc/resolv.conf
search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

自然会直接请求 kube-dns

5)CoreDNS 对于非集群内域名,则会根据当前节点上的 /etc/resolv.conf 转发到外部
DNS
服务器。

Kube-dns 的 Corefile 中相关配置如下:

    .:53 {
        errors
        health {
           lameduck 5s
        }
        // 省略...
        forward . /etc/resolv.conf {
           max_concurrent 1000
        }
    }

省略了其他无关配置,
forward . /etc/resolv.conf
表示,遇到无法解析的请求时会根据 /etc/resolv.conf 文件中的配置进行转发。

而 CoreDNS Pod 中的 /etc/resolv.conf 文件又是 Pod 启动时从当前节点 copy 进去的,因此具体转发到哪儿就和 Pod 启动时节点上的 /etc/resolv.conf 配置有关。

为什么是 169.254.20.10 ?

为什么访问
169.254.20.10
这个
IP
就可以访问到 NodeLocalDNS ?

NodeLocalDNS 以 DaemonSet 方式运行,因此会在集群中每个节点上都启动一个 Pod。该 Pod 会为当前节点增加一张网卡,并将 IP 指定为
169.254.20.10

就像下面这样:

47: nodelocaldns: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN group default
    link/ether 56:9b:08:18:a6:75 brd ff:ff:ff:ff:ff:ff
    inet 169.254.20.10/32 scope global nodelocaldns
       valid_lft forever preferred_lft forever

NodelocalDNS 会以 hostNetwork 网络模式启动,并在前面新增网卡对应 IP (169.254.20.20)上启动服务。

由于我们前面的配置(修改 kubelet 或者 Pod 的 dnsConfig),Pod 里优先级最高的 DNS 服务器就是 169.254.20.20,因此 Pod 需要 DNS 解析时会优先访问 169.254.20.10,最终请求被同节点的 NodelocalDNS Pod 处理。

添加这个网卡的具体作用如下:

  1. 本地 DNS 服务:
    nodelocaldns
    在每个节点上运行,通过监听
    169.254.20.10
    地址提供本地 DNS 服务。这个地址是一个 link-local 地址,仅在本地节点可达。Pod 内的 DNS 查询会被重定向到这个地址,从而实现在节点内解析服务的域名。
  2. 避免 DNS 查询离开节点: 由于
    nodelocaldns
    提供了节点内的 DNS 解析服务,这张网卡确保 DNS 查询不会离开节点。这对于集群内部的 DNS 查询来说是非常高效的,不需要离开节点就能解析服务的域名。
  3. 降低 DNS 查询延迟: 由于
    nodelocaldns
    在每个节点上运行,节点内的 DNS 查询可以更快速地完成,而不必经过集群网络。

简单的做一个实验

# 创建一个新的网络接口 mynic
sudo ip link add mynic type dummy

# 分配IP地址给 eth1
sudo ip addr add 1.1.1.1/24 dev mynic

# 启动你的程序,让它监听在指定的IP地址上
# 例如,如果你有一个基于Python的简单HTTP服务器:
python3 -m http.server 9090 --bind 1.1.1.1

# 同一节点打开新终端测试能否访问到
curl 1.1.1.1:9090

是可以直接访问到的,NodeLocalDNS 添加网卡就是这个作用。

至于为什么是 169.254.20.10 这个 IP?

则是因为 169.254.0.0/16 地址范围是专门用于 link-local 通信的。这意味着这些地址仅在同一子网内可用,并且不需要经过路由器来进行通信。

在这个网络内使用 169.254.20.10 而不是.1 .2 这些则是留出几个位置,以避免冲突。

5. 小结

CoreDNS 本身性能差是因为
跨节点访问导致的大量性能损耗
,同时由于内核 DNAT bug 导致超时等情况。

NodeLocal DNSCache 具有以下优势:

  • 减少了平均 DNS 查找时间
  • 从 Pod 到其本地缓存的连接不会创建 conntrack 表条目。这样可以防止由于 conntrack 表耗尽和竞态条件而导致连接中断和连接被拒绝。

使用 NodeLocalDNS 后性能提升接近 40%, DNS 解析延迟从 39ms 降低到 24ms,且报错次数大幅下降。

NodeLocalDNS 则使用 DaemonSet 方式启动在每个节点都启动一个 Pod,同时使用 hostnetwork + link-local 地址来保证 Pod 中的 DNS 请求只会请求到本地的 NodeLocalDNS Pod,从而避免了跨节点问题,大幅提升性能。

最后 NodeLocalDNS 使用 Link-local 地址也避免了默认情况下使用 service 的 clusterIP 需要 iptables/ipvs 等规则跳转的的问题,在同节点基础上也实现了略微的性能提升。

因此,对于大规模集群,存在高并发的 DNS 请求,推荐使用 NodeLocal DNSCache。

6. 参考

在 Kubernetes 集群中使用 NodeLocal DNSCache

使用NodeLocal DNSCache

DNS 超时问题分析

DNS 压测

lixd/nodelocaldns-admission-webhook

前言

书接上文,prometheus已经安装好了,并且能够对k8s的整体状态进行监控,但是我们还需要更多

环境准备

组件 版本
操作系统 Ubuntu 22.04.4 LTS
docker 24.0.7
grafana 11.2.2

下载编排文件

本文所有的编排文件,
都在这里

▶ cd /tmp && git clone git@github.com:wilsonchai8/installations.git && cd installations/prometheus

安装grafana

prometheus提供了原生的PromQL,但是并不是每次都是需要使用PromQL查询监控数据的,把常用的监控数据固化下来形成图标,才是最好的选择,所以可以借用grafana解决问题

安装grafana我们就不拘泥于在k8s里面了,为了方便,直接单机部署即可,数据文件放入当前的grafana目录

▶ docker run -d --network=host --name=grafana \
    -p 3000:3000 \
    -v ./grafana:/var/lib/grafana \
    registry.cn-beijing.aliyuncs.com/wilsonchai/grafana:11.2.2

检查是否安装成功

配置grafana

1. 添加数据源,选择prometheus

2. 填入prometheus的地址

参考之前prometheus的安装,
http://192.168.49.2:32648/

保存并测试通过

3. 检查数据源

4. 创建panel

开源模版
下载一个适合的模版,然后导入

可以直接使用模版的id,当然也可以下载下来导入json

导入之后选择数据源

5. 导入完成

当然有些数据是需要修复的
_

联系我

  • 联系我,做深入的交流


至此,本文结束
在下才疏学浅,有撒汤漏水的,请各位不吝赐教...

API网关选择:YARP还是Ocelot?

摘要

随着微服务架构的流行,API网关在系统架构中扮演着越来越重要的角色。在.NET生态中,YARP(Yet Another Reverse Proxy)和Ocelot是两种常用的API网关解决方案。那么,在实际应用中,我们该如何选择?本文将从易用性、文档、负载均衡、限流、身份验证、授权和性能等多个方面,对YARP和Ocelot进行详细对比,并附上具体的代码示例,帮助大家更好地理解和选择适合的API网关。

概述

YARP

YARP(Yet Another Reverse Proxy)是由微软维护的一个反向代理库,专为构建高度自定义的反向代理而设计。尽管它是一个较新的项目,但在GitHub上已经获得了超过8.7k的星标。YARP的优势在于其高性能、灵活的配置和丰富的文档支持。

GitHub地址:
https://github.com/microsoft/reverse-proxy

Ocelot

Ocelot也是一个流行的API网关解决方案,已经存在了相当长的时间,积累了丰富的功能和社区支持。在GitHub上,Ocelot拥有超过8.4k的星标。尽管曾经有一段时间维护不积极,但近期又重新活跃起来。Ocelot提供了开箱即用的服务发现、请求聚合等功能。

GitHub地址:
https://github.com/ThreeMammals/Ocelot

功能对比

负载均衡

两者都支持负载均衡,且配置方式相似。都可以通过配置多个下游服务,实现请求的负载均衡,并支持多种负载均衡策略,如轮询、随机等。

限流

YARP

YARP利用
ASP.NET Core内置的限流中间件
,提供了灵活的限流策略。支持固定窗口、滑动窗口、令牌桶、并发限制等多种算法。此外,YARP还支持自定义限流算法,满足特殊需求。

Ocelot

Ocelot也提供了限流功能,但在灵活性上略逊一筹。Ocelot的限流主要基于固定窗口,配置相对简单,但自定义能力有限。

身份验证与授权

YARP

YARP支持与
ASP.NET Core的身份验证和授权机制
集成。可以定义自定义的授权策略,支持基于声明、角色等多种方式的授权。配置灵活,能够满足复杂的安全需求。

Ocelot

Ocelot也支持身份验证和授权,但主要以基于声明的授权为主。相比之下,Ocelot的授权配置较为简单,灵活性不如YARP。

性能

在性能测试中,YARP显著优于Ocelot。在相同的测试条件下,YARP每秒处理的请求数比Ocelot高出约25%。对于高负载、高并发的应用场景,YARP的性能优势更加明显。

实践示例

接下来,我们通过具体的代码示例,展示如何使用YARP和Ocelot构建API网关,并实现负载均衡、限流等功能。

项目结构

我们使用基架自带的
weatherforecast
API。我们的目标是将该API置于API网关之后,使用YARP和Ocelot分别实现。

使用YARP构建API网关

1. 创建YARP网关项目

新建一个空的ASP.NET Core Web应用程序,命名为
YarpGateway

2. 安装YARP包


YarpGateway
项目中,安装YARP的NuGet包:

Install-Package Yarp.ReverseProxy

3. 配置
Program.cs


Program.cs
中,添加YARP所需的服务和中间件:

var builder = WebApplication.CreateBuilder(args);

// 添加YARP反向代理服务,并加载配置
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));

var app = builder.Build();

// 配置YARP中间件
app.MapReverseProxy();

app.Run();

4. 配置
appsettings.json


appsettings.json
中,添加
ReverseProxy
配置节:

{
  "ReverseProxy": {
    "Routes": {
      "forecast-routes": {
        "ClusterId": "forecastCluster",
        "Match": {
          "Path": "/forecast/{**catch-all}"
        }
      }
    },
    "Clusters": {
      "forecastCluster": {
        "Destinations": {
          "destination1": {
            "Address": "http://localhost:5001/"
          },
          "destination2": {
            "Address": "http://localhost:5002/"
          }
        },
        "LoadBalancingPolicy": "RoundRobin"
      }
    }
  }
}

说明:

  • Routes
    :定义了请求匹配规则,将匹配到
    /forecast/*
    的请求路由到
    forecastCluster
  • Clusters
    :定义了下游服务的集合,这里配置了两个目标地址,用于负载均衡。
  • LoadBalancingPolicy
    :设置负载均衡策略为
    RoundRobin
    (轮询)。

5. 添加限流

在YARP中,可以利用ASP.NET Core的限流中间件进行配置。

首先,在
Program.cs
中添加限流服务:

builder.Services.AddRateLimiter(_ => 
{
    _.AddFixedWindowLimiter("fixed", options =>
    {
        options.Window = TimeSpan.FromSeconds(10);
        options.PermitLimit = 10;
        options.QueueLimit = 0;
        options.QueueProcessingOrder = System.Threading.RateLimiting.QueueProcessingOrder.OldestFirst;
    });
});

然后,在YARP的路由配置中,添加限流策略:

{
  "ReverseProxy": {
    "Routes": {
      "forecast-routes": {
        "ClusterId": "forecastCluster",
        "Match": {
          "Path": "/forecast/{**catch-all}"
        },
        "RateLimiterPolicy": "fixed"
      }
    },
    // 其他配置
  }
}

最后,在
Program.cs
中添加限流中间件:

app.UseRateLimiter();

6. 配置身份验证与授权

假设需要对
/products
路由进行授权,我们可以在YARP的路由配置中添加
AuthorizationPolicy

{
  "AuthorizationPolicy": "RequireAuthenticatedUser"
}


Program.cs
中,添加身份验证和授权服务,这里以
JWT
Scheme为例:

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://your-auth-server";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAuthenticatedUser", policy =>
    {
        policy.RequireAuthenticatedUser();
    });
});

并添加中间件:

app.UseAuthentication();
app.UseAuthorization();

使用Ocelot构建API网关

1. 创建Ocelot网关项目

新建一个空的ASP.NET Core Web应用程序,命名为
OcelotGateway

2. 安装Ocelot包


OcelotGateway
项目中,安装Ocelot的NuGet包:

Install-Package Ocelot

3. 配置
Program.cs


Program.cs
中,添加Ocelot所需的服务和中间件:

var builder = WebApplication.CreateBuilder(args);

// 添加Ocelot服务
builder.Services.AddOcelot();

// 加载Ocelot配置文件
builder.Configuration.AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);

var app = builder.Build();

// 配置Ocelot中间件,要加一个await还挺奇怪的
await app.UseOcelot();

app.Run();

4. 创建
ocelot.json

在项目根目录下,添加
ocelot.json
配置文件:

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/forecast/{everything}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5001
        },
        {
          "Host": "localhost",
          "Port": 5002
        }
      ],
      "UpstreamPathTemplate": "/forecast/{everything}",
      "UpstreamHttpMethod": [ "GET", "POST", "PUT", "DELETE" ],
      "LoadBalancerOptions": {
        "Type": "RoundRobin"
      }
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": "http://localhost:4000"
  }
}

说明:

  • Routes
    :定义了请求的上游和下游路径模板,以及支持的HTTP方法。
  • DownstreamHostAndPorts
    :配置了下游服务的主机和端口,用于负载均衡。
  • LoadBalancerOptions
    :设置负载均衡策略为
    RoundRobin
    (轮询)。
  • GlobalConfiguration
    :全局配置,可以配置API网关的基本地址。

5. 添加限流

在Ocelot中,可以在路由配置中添加限流选项:

{
  // 其他配置
  "RateLimitOptions": {
    "ClientWhitelist": [],
    "EnableRateLimiting": true,
    "Period": "10s",
    "PeriodTimespan": 10,
    "Limit": 10
  }
}

6. 配置身份验证与授权

在Ocelot的配置中,添加身份验证和授权选项:

{
  // 其他配置
  "AuthenticationOptions": {
    "AuthenticationProviderKey": "Bearer",
    "AllowedScopes": []
  },
  "RouteClaimsRequirement": {
    "role": "admin"
  }
}


Program.cs
中,添加身份验证和授权服务:

builder.Services.AddAuthentication("Bearer")
    .AddJwtBearer("Bearer", options =>
    {
        options.Authority = "https://your-auth-server";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateAudience = false
        };
    });

// Ocelot会自动使用ASP.NET Core的授权策略

测试配置

向YARP和Ocelot网关发送请求:

  • YARP网关

    GET http://localhost:3000/forecast/weatherforecast
    
  • Ocelot网关

    GET http://localhost:4000/forecast/weatherforecast
    

3. 验证负载均衡

多次发送请求,观察响应中服务器返回的实例信息,确认请求被轮询分配到不同的下游服务实例。

4. 验证限流

快速连续地发送超过限制次数的请求,观察是否返回相应的限流错误信息:

  • YARP
    :默认返回
    503 Service Unavailable

  • Ocelot
    :返回
    429 Too Many Requests
    ,并包含错误信息。Ocelot的返回值更人性化

5. 验证身份验证与授权

使用dotnet签发本地测试JWT,尝试在不提供有效身份验证信息的情况下访问受保护的路由,验证是否被拒绝访问。
image

性能测试

为了对比YARP和Ocelot的性能,我们使用
k6
工具进行压力测试。三个项目都将运行在.NET9.0 Runtime、Release、Logging=Warning模式下

1. 设置测试脚本

创建
yarp-test.js

ocelot-test.js
脚本,内容如下:

import http from 'k6/http';
import { check } from 'k6';

export const options = {
    stages: [
        { duration: '10s', target: 20 },
        { duration: '50s', target: 20 }
    ]
};

export default function () {
    const res = http.get('http://localhost:3000/forecast/weatherforecast');
    check(res, {
        'status was 200': (r) => r.status == 200,
    });
}
import http from 'k6/http';
import { check } from 'k6';

export const options = {
    stages: [
        { duration: '10s', target: 20 },
        { duration: '50s', target: 20 }
    ]
};

export default function () {
    const res = http.get('http://localhost:4000/forecast/weatherforecast');
    check(res, {
        'status was 200': (r) => r.status == 200,
    });
}

2. 运行性能测试

使用以下命令运行测试:

k6 run yarp-test.js
k6 run ocelot-test.js

3. 结果对比

image

根据测试结果,统计每秒处理的请求数(RPS):

  • YARP
    :约84120 RPS。
  • Ocelot
    :约57126 RPS。

YARP的性能明显优于Ocelot,快了47.3%,尤其是在高负载场景下。

结论

通过以上对比,我们可以发现:

  • YARP
    在性能、限流和授权的灵活性方面更具优势,适合需要高性能和高度自定义的应用场景,也更适合熟悉ASP.NET Core中间件的开发人员。
  • Ocelot
    提供了更丰富的功能,如服务发现、请求聚合等,开箱即用,适合快速构建和部署。

参考链接

本文来源:
《华为云DTSE》第五期开源专刊
,作者:
华为云开发者支持首席布道师汪盛

开源、云的增长模式与 Product Led Growth具有较大相似性,两者增长立足于产品质量与使用的开发者数量上,即通过使用规模的扩张,带动市场的扩展。在产品与应用传播中,“能力开放”、“场景化”是核心的两个要素,在发展循环中,如何定位开发者的作用,如何看待开源的商业闭环,是确定开发者技术服务作用的关键问题,由此可以进一步确定开发者技术服务的核心主轴与工作,即开发者是链接行业应用的关键,开发者技术服务聚焦于为场景型开发者提供云架构下的应用开发支持。

前言

开源在大量在云技术以及业务中应用,从开源与云的增长模式看,开源与云具有相当程度的相似性。

友商A1全球云架构战略副总裁曾说过“我们完全看不到开源和A1的业务跟商业利益之间有任何的冲突。关键看客户需要什么。在当前,开源已成为主流,不做开源软件去谈开放是很困难的事。”

为了更好的理解这段话,首先我们需要理解背后的增长逻辑,理解 PLG 模式。

Product - Led - Growth(PLG)PLG模式

风投公司OpenView于2016年提出相关模式。PLG 聚焦用户的增长模型,依赖于产品自身作为获取、转化、扩展客户的核心动力。

Product-Led Growth 简单的说是
Build a Product That Sells Itself
。PLG跟 SLG(销售驱动增长)相对,两者侧重不一样。SLG评价增长依靠销售额,而 PLG 模式下销售的权重降低,更关注软件最终使用者/软件开发者数量增长,通过提高产品传播度,最终用户自助试用、学习应用并自助付费。

单纯从商业角度看,PLG具备如下特点:

1、以产品为中心,产品质量和用户体验高于一切

在一家以产品为主导的增长型公司中,产品本身在很大程度上承担着引入新用户的重任。这种类型的组织不会像其他公司那样将更多的资源投入到销售、营销、广告或其他获取客户的策略上。出于这个原因,产品团队需要打造世界级的产品,成为一个能够快速、轻松地为其市场解决实际问题的公司,促进用户可以从使用产品中马上受益。

2、依赖数据运营,驱动产品改进

以产品为主导的公司知道他们的产品是他们最好的——有时是他们唯一的——销售和营销代表。用户如何与他们的产品进行交互,以及相关反馈是其获得市场反馈的重要手段。

通过数据运营,了解客户在哪些方面发现了产品的价值,了解产品的哪些方面会让用户感到沮丧,或者以任何方式让他们失望,以至于流失用户。只有对使用数据的持续关注,以产品为主导的团队才能做出正确的决策,决定将有限的资源优先安排在哪里,以持续地使产品更好地适合其角色。

3、产品的销售/付费率是重要的衡量指标

以产品为主导的最好指标是产品的销售/付费率。商业实践中,大多数公司仍然遵循收入驱动的模式,但市场推广策略不再严重依赖于昂贵的营销活动,而是由产品质量赢得用户的认可,简单而说,产品在自我销售,用户成为付费客户。

在PLG模式下,SaaS、云厂商产品可以更低成本获得客户、增长速度更快。这是资本市场对PLG青睐的核心原因。典型代表有MongoDB、Elastic、Gitlab、Atlassian、Zoom、Slack等,都有着快速增长和高估值的特点。

云、开源与 PLG 的结合点

云服务产品具有良好的可获得性,如采用PLG模式,必定会遇到的问题就是“传播成本”以及软件行业会遇到的“千行百业的需求与开发资源有限”的问题或矛盾。

开源,提供了一种解决方案:即通过开源软件,广泛的吸引开发者,开发者的使用促进了各行各业的应用,再基于传播过程中,推动商业化。

从经济模型上说,开源软件 = 低传播成本 + 传播承载体。 而这进一步发展出 Developer Focused-PLG
开发者为中心的PLG模式 ,在这个模型中,开发者成为商业模式跑通的核心纽带。

在产品与应用传播中,“能力开放”、“场景化”是核心的两个要素。

以案例看,开源与开发者、商业发展的关系

1、从公开信息看,友商A1针对不同开源场景分为三类策略。

a. 针对第三方的,合作兼容

对接
:商业版本与开源生态对接,如CloudWatch与Prometheus、Grafan等

封装
:客户需求大的开源项目封装成云服务、如K8s,Tensorflow等

合作
:与开源发行版企业开展合作提供云上服务,如openshift、Redis、Confluent、Kafka等

b. 针对自有开源,建设商业闭环

线上到线下
:与线上版本的API和操作方式保持一致,通过开源增加线下触达场景

业务搬迁
:开源数据迁移工具,通过兼容友商API和数据规范,引导用户搬迁

变端侧开源引流
:边缘/端侧项目全部开源,与客户在边缘/端侧联合创新,引流云服务变现,如 IoT Greengrass

推动产品升级,
从能用到易用
:加强云产品的交互性、易用性和用户体验,如CDK、Copilot等

c. 持续影响开源社区

传统行业云化改造:
借助大型基金会的力量触达传统行业:通过发起新开源颠覆传统行业的技术栈、开发模式,

优势领域持续投入:
保护已有投资,保持社区领导者地位,例如,投资Rust,主导Rust异步编程框架Tokio

2、从我们从以上开源策略看,核心聚焦于将开发者与自家产品结合起来

简单地说,不仅提供技术开源,同时提供基于自身产品的场景化的Solution开源,最大化吸引公司开发者等有商业价值开发者试用、使用。

另一方面,做好行业性应用,行业Know How 是核心,而其承载在行业专家、伙伴机构中,这恰恰又是A1劣势。业界行业专家具备行业know how,而不具备云架构能力;A1具备云服务产品能力,但是无法支撑各个行业、企业进行云业务服务。这里正是合作的机会。

A1以行业为细分,利用自身与伙伴能力,建设了公共的行业解决方案框架。此框架,按照华为习惯,可以认为“行业应用优秀实践”。 特点是将云服务解决方案的公共能力抽取出来,形成公共的、可以独立运行的实例解决方案,拥有良好的、成熟的云服务架构、典型的抽象后的云服务应用场景,并具备可扩展性。利用这些框架,开发者可以迅速的构建起能使用的云服务。并通过API机制,开放了南北向集成能力,开发者可以迅速利用自身场景、行业能力与A1提供的云架构能力,构建行业云服务。

3、开发者的参与,与枢纽作用

首先在技术层面,A1以开发者为中心, 提供了核心技术开源、开发工具开源、开源 Solution、以及基于API的能力开放集成能力,迅速吸引大量普通开发者使用A1技术、产品、服务; 在此基础上,先进的技术、更方便的应用工具平台等驱动公司、伙伴中大量技术中坚力量成为A1技术的高价值开发者,这些开发者形成了扎实的转商基础。

在最终商业性组织中的核心开发者,基于自身需要解决的场景问题,或者其客户委托需要解决的场景开发问题,参考开源solution的2次商业性开发,实现了商业闭环。

在这三步走的过程中,A1从最初提供基础框架能力、到开发者支持服务中,最终实现了商业利益。

加强场景化的DTSE技术服务是开源商业化的核心关键

谈起开源,第一想法是“基金会”,是“社区”。 这是自然的,因为这个是开源基础玩法。 另一方面,开源从一开始就是与商业化息息相关的,这段历史可以不谈,然而现实中的开源的商业化之路,一直有意无意避免谈及却是需要值得商榷的。DTSE技术服务核心是构建其中的场景桥梁、能力基础、服务伙伴。

1、开源商业的基础在于技术与能力的开放。

技术与能力开放是开源工作中一直在做的。这里强调的是,开放不仅是技术框架开放,更多是从能力的角度去看开放,需要包含开发者获取足够可用的技术框架、产品能力、周边产品能力、技术支持、工具等开发便利性等综合要素。

对于普通开发者,除了单独产品能力的开放,无论是云服务模式、API接口、还是开源源代码,或其他形式的可获得、可使用的能力之外,针对其场景化能力、系统性能力、开发工具、集成与被集成等相关能力也非常关键。例如,友商提供了帮助企业和开发者快速构建开发测试环境,比如一键安装,提供满足应用云原生开发和测试的一站式需求的镜像以及开源软件,社区版本应及时的响应,从“社区版” 迁移到“商业版”的迁移工具与能力等。

针对于开发环节,友商提供了开放的IDE、 IDE插件、不同语言开发SDK、自动化测试工具集、技术框架、前端/后端应用框架,将产品能力与应用开发过程、测试过程、部署过程紧密结合。甚至java读取excel 小工具,Json解析小工具等便捷性工具也一并提供了。

能力开放、场景化的核心作用在于抢占开发者入口,面向开发者,开发者工作界面需要融合产品核心能力的API、以及开发IDE及插件、SDK、代码、案例、文档、第三方能力集成,核心都在支持开发者尽快使用起来。

2、广泛的开发者基础是黑土地

社区是广泛开发者的经营阵地。面对社区内开发者,需要加强运营,其核心目标在于吸引用户、获得反馈、加速生态建设与商业化,最终促进 PLG循环的加速循环。

开发者的广泛性体现在了多群体、多社区,运营需要关注多社区、线上线下的联合运营。在成长使能中,开发者的开发平台,能力offering 建设,文档等技术支持内容为开发者能力成长提供基础保障;issue、bug工单、版本的及时跟进是建立互动、互信等忠诚度的基础,而细分领域的线上线下活动加强了开发者对技术\产品追随的信心。

3、细分领域场景的构建,是DTSE在开源商业化的最大贡献

商业化核心之一是场景问题的解决,DTSE技术服务,链接最终客户场景,链接华为云技术,正是应该发挥自身优势的职责与机会。

从第四部分的案例,场景化的solution建设,一方面是客户熟悉场景,缺乏云技术;而云服务商提供云技术,缺失场景,缺失贴身服务,伙伴拥有一定的场景、云技术、人力优势,正是 DTSE 将场景传递给云服务商,而将云技术传递给伙伴,以及最终客户的关键工作场景。

4、从工作上看,同样分为三种策略与工作方法

a. 针对第三方,特别是高频应用,聚焦能力对接到华为云,建设 xx for Huawei

对接
:商业版本与开源生态对接

封装
:客户需求大的开源项目封装成云服务

合作
:与开源发行版企业开展合作提供云上服务

b. 针对自有开源,建设场景化solution能力,主导商业闭环

能力开放与场景触达
:框架、API等能力开放,触达到客户场景

技术服务推动能力共建:
独立,或者联合伙伴,面向客户场景,共建能力方案

推动产品升级,从能用到易用:
在方案构建过程中,推动广泛产品的应用,加强云产品的产品能力升级、提升交互性、易用性和用户体验,如CDK、Copilot等

c. 持续影响开源社区

能力的二次开放:
面向已构建的能力,抽取公共能力,对外进行二次开放,以成功案例,如场景化solution框架,再次吸引更多同类场景的客户开发者的关注。

开发者的二次拓展:
一方面通过已新开放的能力,持续吸引新的社区开发者,另一方面,在场景开放中,以及核心能力开放中,继续加强伙伴性开发者、同类客户的客户开发者的技术支持与能力offering建设。

总结

开源、云的商业模式在基本的发展逻辑上与Product Led Growth 具有较大的相似性,在发展路径上,开发者是其Product Sells Itself运行逻辑顺利发展的驱动和能力传播承载体。能力开放、应用场景是开发者能体验、使用、应用、传播的核心要素。

DTSE技术服务聚焦核心点应在社区广泛开发者的通用技术支持,以及更重要的商业化能力建设。即:在场景化能力建设中,将云服务商技术与客户场景需求对接,以技术能力解决客户问题,构建场景化的通用solution能力,以及其中构筑的新能力的2次传播与新客户型开发者的技术服务,形成业务的滚动传播与覆盖。

点击关注,第一时间了解华为云新鲜技术~