QUIC 存在 UDP 反射 DDoS 攻击漏洞吗?

今年年初,360信息安全部发布了一篇关于利用 UDP 反射 DDoS 的分析报告:Memcache UDP反射放大攻击技术分析。报告一出,引起了业界的普遍关注。根据文中所述,光是Qrator Labs 在 medium.com 上 批露的一次DDoS攻击看,其攻击流量峰值达到 480Gbps。而360信息安全团队本身也监测和确认有更大的攻击已经实际发生,只是未被公开报道。

而就在这个这个事件纰漏没多久,我把博客升级为支持基于 UDP 的 QUIC 协议来改善小站的访问体验:本站开启支持 QUIC 的方法与配置。本着小站没几人访问的蜜汁自信,当时也没太纠结 QUIC 是否也存在 UDP 反射漏洞。前几天,看到著名博主,阮一峰同学网站被 DDoS 攻击,心里咯噔一下:出来混迟早是要还的,还是填坑为安吧。

什么是 UDP 反射 DDoS 攻击

简单讲,就是攻击者利用IP网络不做真实源地址检查的“设计缺陷“,向提供基于 UDP 服务的服务器发送伪造源地址(一般为被攻击者的主机IP)的 UDP 报文请求,使得这些 UDP 报文的响应数据都会发送给被攻击者主机,这种攻击我们称之为 UDP 反射 DDoS 攻击。

之所以要通过被利用的服务器反射流量到被攻击的服务器,是因为被利用的服务器一般存在流量放大效应。即一个伪造IP的 UDP 请求发送到到被利用服务器后,被利用服务器会发送比请求更多的数据到被攻击者服务器。

被利用服务器输出流量与输入流量的比值我们称之为放大系数。这个系数与被利用服务器所提供的 UDP 服务有关。之前提到的利用 Memcache 漏洞的 DRDoS 攻击,可以获得稳定的 60000 倍放大系数。而我们日常使用的 DNS 则可以轻松的获得 50 倍的放大系数。

由放大系数反推,我们可以知道,如果一个 UDP 服务被利用以后,放大系数小于等于1的话,则不存在利用价值,因为这个时候,只从带宽流量方面考虑的话,还不如直接利用攻击主机对被攻击服务器进行攻击效率高。

QUIC 存在 UDP 反射攻击漏洞吗

按照蛤乎惯例,照顾猴急的同学,先给结论:可以。

QUIC 主要通过以下机制来解决该问题:

  1. 对于首次发起建立 QUIC 连接的客户端,服务端要求其初始化的 hello 数据包必须完全填充。这个包在 IPv4 下一般是 1370 字节,在 IPv6 下是 1350 字节。在 QUIC 协议中,服务器和客户端数据交互的基本单位是就是 UDP 数据包,而一个全填充的数据包已经达到了数据包大小的上限,因此服务器的响应数据包一定是小于等于这个 hello 数据包的。显然,放大系数小于等于1. 因此,新连接的建立没有反射利用的价值。
  2. 建立 QUIC 连接后,客户端发出的数据包就不会全填充了。这个时候,如果被 UDP 反射利用,放大系数是大于1的。因此, QUIC 引入了源地址token (source address token):在成功建立 QUIC 连接后,服务器会用发放一个源地址token给客户端,并要求客户端在后续的数据包中带上这个token;服务器只对源地址token有效的数据包进行处理。源地址token中一般包含客户端的源地址和服务器的时间。因此这是一个客户端证明其IP所有权的凭证。
  3. 由于源地址token可能会被网络中的攻击者嗅探收集,因此 QUIC 设计了一套源地址token的过期和刷新机制。另一方面,每次客户端发送的数据包如果都带上源地址token的话,不仅客户端流量大,服务器验证token也是额外的开销,使得协议延迟变高。因此 QUIC 协议允许客户端按照一个动态策略选择是否在数据包中夹带源地址token:服务器端收集和统计源地址的数据包,当统计到源地址数据包交互响应异常的数量超过阈值时,要求该源地址的客户端必须夹带源地址token, 对于无法提供合法源地址的token的请求进行 reject 处理。

扩展阅读

容器环境下 go 服务性能诊断方案设计与实现

背景

业务上量以后,对程序进行 profiling 性能诊断对很多后端程序员来说就是家常便饭。一个趁手的工具往往能让这个事情做起来事半功倍。

在这方面,go 有着天然的优势:继承 Google’s pprof C++ profiler 的衣钵,从出生就有 go tool pprof 工具。并且,标准库里面提供 runtime/pprofnet/http/pprof 两个package, 使得 profiling 可编程化。

在非容器环境下,我们的研发同学喜欢使用 net/http/pprof 来提供http接口供 go tool pprof 工具进行 profiling:

import _ "net/http/pprof"

func main(){
    ...
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    ...
}

获取 CPU profile 数据:

go tool pprof http://localhost:6060/debug/pprof/profile

但是,当架构逐步演进为微服务架构并使用k8s等容器化技术进行部署以后,这种这种方式面临的问题也越来越多:

  1. 我们生产环境使用k8s进行容器编排和部署。service类型是 NodePort. 因此研发同学无法直接对某个 service 的特定 pod 进行 profiling. 之前的解决方式是:
    1. 如果要诊断的问题是这个service普遍存在的问题,则直接进行 profiling。
    2. 如果要诊断的问题只出现在这个service的某个特定的pod上,则由运维同学定位到该pod所处的宿主机后登录到该容器中进行profiling。耗时耗力,效率低。
  2. 架构微服务化以后,服务数量呈量级增加。以前那种出现问题再去诊断服务现场的方式越来越难满足频率和数量越来越多的profiling需求(很多情况下,我们才做好profiling的准备,问题可能已经过去了……)。我们迫切的需要一种能够在程序出问题时,自动对程序进行profiling的方案,最大可能获取程序现场数据。
  3. 同时,我们希望这种自动profiling机制对程序性能影响尽可能小,并且可以与现有告警系统集成,直接将诊断结果通知到程序的owner.

方案设计与实现

  • 我们使用 heapster 对k8s的容器集群进行监控。并将监控到的时序数据写入influxDB进行持久化。
  • gopprof 是我们容器环境下对其他 go 服务进行性能诊断的核心服务:
    • 通过对influxDB中的监控数据分析,对于异常的pod自动进行 profiling. 当前设置的策略是如果该pod在两个1分钟分析周期内,资源使用率都超过设定的阈值0.8,则触发profiling。
    • gopprof 作为一个服务部署在k8s集群中主要是使其可以通过内网IP直接访问pod的 http profile接口,已实现对特定pod的profiling:
    go tool pprof http://POD_LAN_IP:NodePort/debug/pprof/profile
    
    • gopprof 完成profiling后,会自动生成 profile svg 调用关系图,并将profile 数据和调用关系图上传云存储,并向服务的owner推送诊断结果通知:

    • 由于 gopprof 依赖工具 go tool pprofgraphivz, 因此gopprof的基础镜像需要预装这两个工具。参考Dockerfile
    # base image contains golang env and graphivz
    
    FROM ubuntu:16.04
    
    MAINTAINER Daniel liudan@codoon.com
    
    RUN apt-get update
    RUN apt-get install wget -y
    RUN wget -O go.tar.gz https://dl.google.com/go/go1.9.2.linux-amd64.tar.gz && \
        tar -C /usr/local -xzf go.tar.gz && \
        rm go.tar.gz
    
    ENV PATH=$PATH:/usr/local/go/bin
    
    RUN go version
    
    RUN apt-get install graphviz -y
    
    • gopprof 向研发同学提供了对特定pod以及特定一组pod进行手动profiling的的接口。在解放运维同学生产力的同时,也让研发同学在出现难以复现的问题时,能够有更大可能性获取到程序现场。
    • 在高可用方面,当前只支持部署一个 gopprof pod, 服务可用性依赖于k8s的的auto restart. 后期如果有这方面的需求,可能会修改为依赖于etcd支持多个gopprof pod部署。

小结

gopprof 服务已经在我们内部落地试运行了一段时间,整个上达到了我们的设计预期,并辅助我们发现和解决了一些之前没有意识到的性能问题。由于有一些内部代码依赖,暂时还无法开源出来。但是整个方案所依赖的组件都是通用的,因此你也可以很容易的实现这个方案。如果你对我们实现中的一些细节感兴趣,欢迎评论和留言。

GopherChina 2018 keynote 点评

作为一名参加了两届GopherChina的「老人」,今年为了去沟里吃樱桃,就没去现场凑热闹了。不过,会议的keynote是绝不会错过的。AstaXie也在会议结束后的第一时间放出了会议的ppt. 看了一下,里面的ppt并不完整,缺了第二天的第一个keynote. 手上有这个资源的同学可以分享我一下🙂

1.1 基于Go构建滴滴核心业务平台的实践

介绍了滴滴老服务迁移到Go的过程。很多内容感同身受,因为在一年前,我们也完成了类似的操作。从slides看,其日志收集、分布式调用追踪等微服务演进过程中解决的问题都是一笔带过,但是其实都是挺花时间的事情。可以参考微服务troubleshooting利器——调用链

比较遗憾的是没有看到其在服务迁移的时候如何确定服务边界和问题领域,更没有深入谈如何拆分低耦合高内聚的微服务的思考。

解决WaitGroup和GC问题比较有意思,了解一下即可。

最后介绍了两个开源工具Gendryjsonitr, 典型的瑞士军刀、直击目标风格,很棒。

Gendry是一个数据库操作辅助工具,核心是sql builder。我非常喜欢其设计理念:为什么要开发Gendry。简单讲,就是在不透明和不易调优的ORM与繁琐、低效的裸写sql之间找一个平衡。

jsosniter则是一个高效的json encodec. 虽然benchmark亮眼,但是我想大部分场景下,我还是会优先选择标准库。因为很多json序列换和反序列化的细节处理上,标准库还是最完善的。

1.2 Go在Grab地理服务中的实践

从slides看,应该是最容易听懂的一个keynote吧。没有贬义的意思,而是对于作者的思维清晰程度和表达能力非常佩服。基于地理位置做供需匹配的同学可以把这个当做范文,看看作者是如何把系统从基于PostGIS开始逐步演进到geohash/redis/shard/cell方案的。

整个内容非常顺畅,似乎作者在现场还普及了一个「能够做叫车服务就能够做送外卖」的梗。

1.3 Rethinking Errors for Go 2

来自 Golang 核心组的 Marcel 同学向大家介绍了Go 2中可能会引入的 error 处理机制。我个人还是能够接受Go 2中这个draft阶段的错误处理方式的。

作者在demo中使用errcerrd两个lib做演示,想了解细节的同学可以直接点进去看看如何使用。

与现有的错误处理方式比较,能够显著减少 if err != nil 这种代码,并且有更强的语言表达能力。虽然很多人吐槽说 Go 2 最终还是可能会引入关键字 try,但是从 Marcel 的介绍看,这只不过是一个语法糖而已,编译时候就inline掉了。另外,即使最终的方案通过 try 实现了更多的其他功能,也没有必要一定要避免try关键字与其他语言撞车的事实吧。毕竟语言设计追求的是尽可能的合理性和正确性,而不是独特性。

Go在区块链的发展和演进

仅从slides看,就是个区块链科普文,当然,不排除作者现场演讲能力比较强,抖了很多现场才能听到的料。如果你已经对区块链比较了解,可以略过。

Badger_ Fast Key-Value DB in Go

一个pure go的基于LSM tree的 key-value 数据库。如果你不是很了解LSM Tree, 可以参考鄙人的拙文:LSM Tree/MemTable/SSTable基本原理。Badger主要有以下几个特点:

  1. pure go实现,没有cgo依赖。
  2. Badger的LSM tree存储的是 {key, value_pointer},因此可以有效降低LSM tree的大小, 直接load到内存。
  3. 印度小哥现场跑分,读写性能比boltDB 和 RocksDB 都有相当优势。
  4. bloom-filter和file merge实现中规中矩。
  5. 支持无锁的并发事物。

开源那是必须的,想进一步研究的同学可以移步dgraph-io/badger.

Golang在阿里巴巴调度系统Sigma中的实践

slides看不出太多架构、思路和案例的内容,可能是一个干货在心中的speaker,ppt只是提词器罢了。Golang语言采坑部分比较基础,稍微有经验的gopher应该都知道。不过从去年对阿里分享的失望看,今年大家对阿里的分享好评率要好很多。

罗辑思维Go语言微服务改造实践

都说这次大会speaker的幽默水平历届最高,来自罗辑思维的方圆老师更是重新定义了「系统可用性」:只要老板觉得是就可以。

分享的内容涵盖了一个中小互联网企业微服务化的方方面面:api gateway, 服务注册、服务发现、多级缓存、熔断降级。基本可以作为一个公司微服务进程第一阶段的范文来研究。微服务化的后续阶段,比如容器化以及与之配合的CI/CD、日志管理、分布式追踪、auto-scale、立体监控,从其展望上看也有计划。因此可以持续关注方圆老师的后续动作。

Golang打造下一代互联网-IPFS全解析

本质上是一个p2p的去中心化分布式存储系统。基于其之上,可以构建各种应用。最promising的当然是http服务。

整个IPFS使用的基本都是现成的,但是却组合出了一个非常有意思的场景应用。因为之前也有关注IPFS,内容本身没有太多其他收获。权当是一次复习吧。

如何用GO开发一个区块链项目

从slides看,就是介绍了一些区块链的基础概念,后面两页ppt才遇到go,一笔带过. 个人没有太多收获。

Bazel build Go

对Bazel不是太熟悉,在看这个keynote前,只在tensorflow 教程中跟着走过一下Bazel,因此看到国内有公司把 Bazel 拿来在实际开发中应用还是心生敬仰的。

就我经历的项目看,go的build和依赖管理都有不错的轻量级工具,使用 Bazel 来 build 应该更加适合大型的多语言混合项目。

基于Go-Ethereum构建DPOS机制下的区块链

又是一个区块链科普文,不过相对更加聚焦到共识算法上。

深入CGO编程

打开slides我就被震惊了,足足145页ppt, 内容也毫无灌水,问题聚焦,示例丰富。 本来觉得自己是知道什么叫做CGO的,但是看完以后,感觉自己才真正开始入门。建议谢大应该给这样负责的speaker加鸡腿。

这应该是我见过的关于Golang中使用CGO最全面、丰富、深入的资料了。虽然在大部分场景下,都会避免使用CGO,但是如果遇到绕不开的场景的时候,这绝对是第一手的学习资料。

runv-kata-gopher-china

kata container: 安全如虚拟机,快如容器。在去年的kubeCOn’17 就发布了,目前还没有看到国内有公司在生成环境使用。持观望态度吧。slides内容太少,脑补不出来。不评价细节了。

Go toolchain internals and implementation based on arm64

介绍了golang arm64 的编译工具链。除了开始提到的AST分析最近体会较深(基于AST写代码生成器),其他的还停留在概念了解上。不过还是向作者深入钻研的精神致敬。

Go在探探后端的工程实践

又是一个公司落地go生态的例子。亮点是在测试部分做得非常全面和细致。对于在落地完善CI流程的同学(比如我),这部分有非常深远的参考意义。

其他

golang从出生开始就提供了非常完善的基于 go pprof 的一系列性能profiling工具,这是很多其他语言羡慕不来的。而今年的会议有一个共同点是,性能调优工具除了使用 go pprof 以外,都会结合使用 Uber 开源的golang火焰图工具go-torch:

著名开源项目OpenResty作者章亦春也非常推崇使用火焰图来诊断性能问题。看来火焰图真的越来越火了😂

看到去了现场的不少同学吐槽这次会议区块链内容比较多。其实我觉得这个topic还好,毕竟会议也需要结合一些当前的热点。比较遗憾的是区块链相关的 slides 质量都不是很高,这可能才是被吐槽的真正原因。

公司层面,现在不仅中小互联网公司大量使用go做基础架构,也越来越多大厂开始使用go构建一些基础组件。相信以后gopher不仅会在创业公司持续活跃,也会有更多到大厂工作的机会。

Golang strings.Builder 原理解析

背景

在很多场景中,我们都会进行字符串拼接操作。

最开始的时候,你可能会使用如下的操作:

package main

func main() {
    ss := []string{
        "A",
        "B",
        "C",
    }

    var str string
    for _, s := range ss {
        str += s
    }

    print(str)
}

与许多支持string类型的语言一样,golang中的string类型也是只读且不可变的。因此,这种拼接字符串的方式会导致大量的string创建、销毁和内存分配。如果你拼接的字符串比较多的话,这显然不是一个正确的姿势。

在 Golang 1.10 以前,你可以使用bytes.Buffer来优化:

package main

import (
    "bytes"
    "fmt"
)

func main() {
    ss := []string{
        "A",
        "B",
        "C",
    }

    var b bytes.Buffer
    for _, s := range ss {
        fmt.Fprint(&b, s)
    }

    print(b.String())
}

这里使用 var b bytes.Buffer 存放最终拼接好的字符串,一定程度上避免上面 str 每进行一次拼接操作就重新申请新的内存空间存放中间字符串的问题。

但这里依然有一个小问题: b.String() 会有一次 []byte -> string 类型转换。而这个操作是会进行一次内存分配和内容拷贝的。

使用 strings.Builder 进行字符串拼接

如果你现在已经在使用 golang 1.10, 那么你还有一个更好的选择:strings.Builder:

package main

import (
    "fmt"
    "strings"
)

func main() {
    ss := []string{
        "A",
        "B",
        "C",
    }

    var b strings.Builder
    for _, s := range ss {
        fmt.Fprint(&b, s)
    }

    print(b.String())
}

Golang官方将strings.Builder作为一个feature引入,想必是有两把刷子。不信跑个分?简单来了个benchmark:

package ts

import (
    "bytes"
    "fmt"
    "strings"
    "testing"
)

func BenchmarkBuffer(b *testing.B) {
    var buf bytes.Buffer
    for i := 0; i < b.N; i++ {
        fmt.Fprint(&buf, "😊")
        _ = buf.String()
    }
}

func BenchmarkBuilder(b *testing.B) {
    var builder strings.Builder
    for i := 0; i < b.N; i++ {
        fmt.Fprint(&builder, "😊")
        _ = builder.String()
    }
}
╰─➤  go test -bench=. -benchmem                                                                                                                         2 ↵
goos: darwin
goarch: amd64
pkg: test/ts
BenchmarkBuffer-4         300000        101086 ns/op      604155 B/op          1 allocs/op
BenchmarkBuilder-4      20000000            90.4 ns/op        21 B/op          0 allocs/op
PASS
ok      test/ts 32.308s

性能提升感人。要知道诸如C#, Java这些自带GC的语言很早就引入了string builder, Golang 在1.10才引入,时机其实不算早,但是巨大的提升终归没让人失望。下面我们看一下标准库是如何做到的。

strings.Builder 原理解析

strings.Builder的实现在文件strings/builder.go中,一共只有120行,非常精炼。关键代码摘选如下:

type Builder struct {
    addr *Builder // of receiver, to detect copies by value
    buf  []byte // 1
}

// Write appends the contents of p to b's buffer.
// Write always returns len(p), nil.
func (b *Builder) Write(p []byte) (int, error) {
    b.copyCheck()
    b.buf = append(b.buf, p...) // 2
    return len(p), nil
}

// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))  // 3
}

func (b *Builder) copyCheck() {
    if b.addr == nil {
        // 4
        // This hack works around a failing of Go's escape analysis
        // that was causing b to escape and be heap allocated.
        // See issue 23382.
        // TODO: once issue 7921 is fixed, this should be reverted to
        // just "b.addr = b".
        b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
    } else if b.addr != b {
        panic("strings: illegal use of non-zero Builder copied by value")
    }
}

  1. byte.Buffer思路类似,既然 string 在构建过程中会不断的被销毁重建,那么就尽量避免这个问题,底层使用一个 buf []byte 来存放字符串的内容。
  2. 对于写操作,就是简单的将byte写入到 buf 即可。
  3. 为了解决bytes.Buffer.String()存在的[]byte -> string类型转换和内存拷贝问题,这里使用了一个unsafe.Pointer的存指针转换操作,实现了直接将buf []byte转换为 string类型,同时避免了内存充分配的问题。
  4. 如果我们自己来实现strings.Builder, 大部分情况下我们完成前3步就觉得大功告成了。但是标准库做得要更近一步。我们知道Golang的堆栈在大部分情况下是不需要开发者关注的,如果能够在栈上完成的工作逃逸到了堆上,性能就大打折扣了。因此,copyCheck 加入了一行比较hack的代码来避免buf逃逸到堆上。关于这部分内容,你可以进一步阅读Dave Cheney的关于Go’s hidden #pragmas.

就此止步?

一般Golang标准库中使用的方式都是会逐步被推广,成为某些场景下的最佳实践方式。

这里使用到的*(*string)(unsafe.Pointer(&b.buf))其实也可以在其他的场景下使用。比如:如何比较string[]byte是否相等而不进行内存分配呢?😓似乎铺垫写得太明显了,大家应该都会写了,直接给出代码吧:

func unsafeEqual(a string, b []byte) bool {
    bbp := *(*string)(unsafe.Pointer(&b))
    return a == bbp
}

扩展阅读

The State of Go 1.10

阳春三月在成都

这是一篇月末总结,如果你是不小心被标题吸引误入,那么我更加建议你把这篇月志读完。既来之,则安之😁

三月工作上发生了挺大变化,很多老朋友离开了,很多新同学加入了。无论是离开还是加入的人,里面都有自己喜欢和欣赏的人。成都的圈子只有这么大,鄙人都祝福他们。

自己又回到3年前的类似位置,做着类似的事情,此乃物是。三年前犯过的错误、苦恼纠结的事情,无一缺席,但是已经没有了当初面对类似事情的不确定性,此乃人非。

这个月重新找到了写字的愉悦感。在某些平台居然还被特别推荐过,我跟妞儿说这是一件funny的事情,没有开心的成分。算是把延期了好几年的课程补上了一小步。

在这个过程中,鄙人也开始逐渐体会到了自己订阅的公众号作者写作的内心感受。写作的目的可能有很多种,但是对于某一类人来说,写作就是一种习惯性的分配特定时间进行思考。这种思考可能在写作开始之前已经在潜意识中发生。写作就是讲这些思考的尘埃落定和梳理的过程。另外,根据我的观察,一般擅长写作的人,大多都是逻辑清晰且沟通起来很愉快的人。

三月挺幸运,包括今天。接触到了很多交流起来很愉快的人。鄙人跟妞儿说,遇到聪明的人的时候,有一种发自内心的欣赏和热爱。这种热爱跟基情没有关系,对于没有那么熟络的人,珍惜交流的效率和时间;对于熟悉的人,会聊很多平时不会聊的话题和看法。要知道,其实这个世界上,能够一起沟通和讨论一些深度想法的人太少太少了,遇到一个,怎能不喜?有那么一群人,注定就是寂寞。

马上而立之年了,责任随不至于越来越重,但是却只越来越真实。这个月似乎也硬着头皮处理了很多家庭上的事情。也许有时候过于追求效率,以及当面揭穿一些人的套路伎俩吧,给人的感觉是脾气太冲,经常惹恼很多人,包括家人。在这个点上,我从来不抱怨什么路长且累呀,什么不理解呀。我挺同意韩路的观点:如果你对目标足够清晰和强壮,那么做一些事的时候必然会有取舍和付出。想通了这件事,很多事情就是理所当然的,哪里还有什么抱怨。我花了挺长时间想清楚这个问题,也会有心情郁闷的时候,也会有到家以后,熄火在车里闷两分钟的时候,但是只要踏入家门,我必然是女儿的好爸爸,妞儿的好丈夫,父母的好儿子,而这一切,内心是举重若轻,毫无纠结的。

三月:

  • 最喜欢的物品:飞行棋手机架
  • 最喜欢的书:《原则》
  • 最惺惺相惜的人:至今不知道他的名字,但是却有过一次非常深入、愉快的交流
  • 财务目标一个都没有达到,且存在浮亏的款项
  • 独立项目新开了一个项目,看能走多远吧

三月开始,南方逐渐进入一年中最好的日子。在期待四月中,2018已经过去25%.

一次非典型性 Redis 阻塞总结

南方逐渐进入一年中最好的时节,用户也开始骚动起来。看了眼数据,活跃用户已经double很远,马上triple了。

一日睡眼惺忪的清晨,正看着数据默默yy时候,线上开始告警…… MMP,用户早上骚动的增长比想象好快呢。同事第一时间打开立体监控瞥了一眼,结合服务的错误日志,很快把问题锁定到了一个Redis实例(事实上,自从立体监控上线以后,基本上处理流程从以前的 < 80%时间定位问题 + 20%解决问题 > 变成了 < 少量时间确认问题 + 解决问题 >)。团队处理效率还是挺快的,原因定位到AOF持久化:

这是当时的Redis配置:

127.0.0.1:6379> config get *append*
1) "no-appendfsync-on-rewrite"
2) "no"
3) "appendonly"
4) "yes"
5) "appendfsync"
6) "everysec"

从配置看,原因理论上就很清楚了:我们的这个Redis示例使用AOF进行持久化(appendonly),appendfsync策略采用的是everysec刷盘。但是AOF随着时间推移,文件会越来越大,因此,Redis还有一个rewrite策略,实现AOF文件的减肥,但是结果的幂等的。我们no-appendfsync-on-rewrite的策略是 no. 这就会导致在进行rewrite操作时,appendfsync会被阻塞。如果当前AOF文件很大,那么相应的rewrite时间会变长,appendfsync被阻塞的时间也会更长。

这不是什么新问题,很多开启AOF的业务场景都会遇到这个问题。解决的办法有这么几个:

  1. no-appendfsync-on-rewrite设置为yes. 这样可以避免与appendfsync争用文件句柄,但是在rewrite期间的AOF有丢失的风险。
  2. 给当前Redis实例添加slave节点,当前节点设置为master, 然后master节点关闭AOF,slave节点开启AOF。这样的方式的风险是如果master挂掉,尚没有同步到salve的数据会丢失。

我们采取了折中的方式:在master节点设置将no-appendfsync-on-rewrite设置为yes,同时添加slave节点。

理论上,问题应该解决了吧?啊蛤,的确是理论上。

修改后第一天,问题又出现了。惊不惊喜,意不意外?

于是,小伙伴又重新复习了一下当时出问题时候的Redis日志:

有两个点比较可以:

  1. 前几条AOF日志告警日志发生在晚上3~5点之间,而那个时候,我们整个系统负载是非常低的。
  2. 清晨的告警日志不是某一个Redis实例告警,而是该机器上的所有Redis实例都在告警。

在这种百思不得骑姐的情况下,结合历史上被坑的经验,我们99%断定是我们使用的云主机存在问题。

这个问题有可能是宿主机超售太多导致单个租户实际能使用到的云盘IO比标称值低,也有可能是租户隔离做得不好,导致坏邻居过度占用IO影响其他租户。

这个很好理解:我们使用的是阿里云的云SSD,而阿里云目前的架构还没有做到计算和存储分离,即计算和存储的网络IO是共享的。

当然目前这个问题还没有实锤,我们也还在跟阿里云积极沟通解决。同时为了避免给自己惹麻烦,我还是留了1%的其他可能性😅

祝大家周末愉快!

参考资料

Redis相关—Redis持久化

Dropbox Bandaid 微服务反向代理/Sevice Mesh 代理解析

随着微服务架构以及的广泛普及,很多公司都会使用或者自行开发自己的API Gateway, 甚至在内部服务也会应用Service Mesh.

不久前,看到了一篇Dropbox公司介绍其内部服务代理Bandaid的文章: Meet Bandaid, the Dropbox service proxy. 不得不说设计细节一贯独角兽风格,非常有收获,对于改进我们自己设计的proxy 也有一定参考意义。这里做一个简单的读后笔记。英语好的同学可以直接阅读原文。(插一句,Dropbox不久前在美股上市了,在中美贸易战中为数不多逆势上涨的股票之一,玩美股的同学可以关注一下。再插一句:股市有风险,投资须谨慎。)

Bandaid 诞生的背景

Bandaid 是有公司内部的反向代理服务演变而来,使用Golang实现。反向代理有很多成熟的解决方案,之所以选择自行开发主要有以下几个原因:

  • 更好的与与内部的基础设施集成
  • 可以复用公司内部基础库(更好的与内部代码集成)
  • 减少对外部的依赖,团队可以灵活的按需开发
  • 更适合公司内某些特殊使用场景

上面的大部分因素根我们进行微服务组件开发时候的考量基本一致。这也是我们当初没有使用Go kit 这种工具套装进行架构微服务改造的顾虑,当然,那个时候还没有这些工具链。

Bandaid 的特性

  • 支持多种负载均衡策略 (round-robin, least N random choices, absolute least connection, pinning peer)
  • 支持 https to http
  • 上下游支持 http2
  • 路由改写
  • 缓存请求与响应
  • host级别的逻辑隔离
  • 配置热加载
  • 服务发现
  • 路由信息统计
  • 支持gRPC代理
  • 支持HTTP/gRPC健康检查
  • 流量支持按权重分配和金丝雀测试

丰富的负载均衡策略,以及对HTTP/2和gRPC的支持实例亮点。要知道,nginx 从 1.13.10才开始支持gRPC.

另一方面,从支持的特性看,如果要求不是特别多,直接用来作为Service Mesh也是相当不错的。而且由于是使用Go开发,对于本来就在使用Go作为技术栈的团队来说,无论是使用还是二次开发,门槛和学习成本都是很低的。

Bandaid 设计解析

整体架构

请求队列设计

接收的请求按照LIFO 后入先出的方式进行处理。这个设计有点反直觉,但却是合理的:

  • 绝大部分情况下,队列应该是空或者接近空的状态。因此LIFO的策略并不会恶化队列最大等待时间。
  • 根据业务类型,可以配置队列的优先级和长度。可以非常方便的实现服务限流、服务降级和熔断器。
  • Bandaid 采用总是接收TCP连接,并将连接交由用户态管理的策略。结合LIFO有一个很大的好处:
    • 与内核态管理连接比较,如果客户端发送请求后意外关闭了TCP连接,Bandaid 是无法马上获取到该错误的,需要等到读取完该请求,然后处理请求后开始写response时才会触发错误,发现这个连接其实已经关闭。因此,处理这类请求是在无谓的消耗服务器资源。而采用LIFO和用户态管理连接的话,Bandaid 可以根据配置的超时策略,一定程度上drop掉这类请求,减少处理这种「dead request」的数量。

Worker 设计

Worker采取固定大小的工作池设计,一方面可以精确的控制并发数量,另一方面,也避免频繁创建worker的开销。不过文中也承认,池的大小在设置的时候需要结合业务考虑清楚,否则可能不能充分利用服务器资源,错误的触发服务降级。

worker在处理队列中的请求时,支持按照优先和权重处理。因此,可以非常容易的实现金丝雀发布和逻辑上的upstream隔离。

负载均衡策略

  • RR, 均匀撒胡椒面。优点是足够简单,缺点是没有考虑不同后端服务实例的数量和接口处理时长差异,会导致大量Bandaid 服务资源被极少数的慢服务和接口消耗掉。
  • least N random choices: 先随机选择N个候选upstream host, 然后选择连接数最少的host (认为是当前负载最低的)作为最终目标host.

这种方式在在大部分时候可以工作得很好,但对于那种快速失败的小服务会失效。因为这种服务有很大概率被选中,但是并不意味着其当前负载较低。缓解该策略的方式是absolute least connection.

  • absolute least connection: 从全局host中选择连接数最少的host作为目标host.

  • pinning peer: 将worker与host绑定。这种方式可以避免慢服务过量消耗Bandaid资源的问题,但是请求调度不够灵活。

总结

从 Bandaid 的设计看,无论是作为 reverse proxy 还是 service mesh 都有不错的潜力。不过 Dropbox 团队当前还没有公开Bandaid性能测试数据,代码也还没有开源。因此,猴急的同学可能还需要等一段时间。

本站开启支持 QUIC 的方法与配置

在越来越讲究用户体验的今天,网络带宽的提高已经很难有显著的页面加载改善,而低延迟的优化往往能够起到意想不到的效果。在《TLS1.3/QUIC 是怎样做到 0-RTT 的》中我们分析了TLS1.3和QUIC在低延迟方面的原理和低延迟优势。在从源代码编译 nginx docker 镜像开启 TLS 1.3中我们已经把玩了TLS1.3,没有理由不把玩一下QUIC,对吧?

起初以为,在普及程度上,QUIC因为主要是Google主导,会曲高和寡。但是,查了一下,发现腾讯早在2017年就在生产环境应用了QUIC:让互联网更快的协议,QUIC在腾讯的实践及性能优化. 结果显示:

灰度实验的效果也非常明显,其中 quic 请求的首字节时间 (rspStart) 比 http2 平均减少 326ms, 性能提升约 25%; 这主要得益于 quic 的 0RTT 和 1RTT 握手时间,能够更早的发出请求。

此外 quic 请求发出的时间 (reqStart) 比 h2 平均减少 250ms; 另外 quic 请求页面加载完成的时间 (loadEnd) 平均减少 2s,由于整体页面比较复杂, 很多其它的资源加载阻塞,导致整体加载完成的时间比较长约 9s,性能提升比例约 22%。

既然大厂都已经发车,我司也就可以考虑跟进了。稳妥起见,决定先在自己的博客开启QUIC,然后再逐步在线上业务进行推广。

方案概览

方案非常简单:不支持QUIC的浏览器依旧通过nginx tcp 443访问;支持QUIC的浏览器通过caddy udp 443访问。

由于nginx近期没有支持QUIC的计划, 作为一名gopher, 因此这里选择caddy作为QUIC的反向代理。后面会介绍caddy的具体安装和配置方法。

对于支持QUIC的浏览器来说,第一次访问支持QUIC的网站时,会有一次服务发现的过程。服务发现的流程在QUIC Discovery
有详细介绍。概括来说,主要有以下几步:

  1. 通过TLS/TCP访问网站,浏览器检查网站返回的http header中是否包含alt-svc字段。
  2. 如果响应中含有头部:alt-svc: 'quic=":443"; ma=2592000; v="39"',则表明该网站的UDP 443端口支持QUIC协议,且支持的版本号是draft v39; max-age为2592000秒。
  3. 然后,浏览器会发起QUIC连接,在该连接建立前,http 请求依然通过TLS/TCP发送,一旦QUIC连接建立完成,后续请求则通过QUIC发送。
  4. 当QUIC连接不可用时,浏览器会采取5min, 10min的间隔检查QUIC连接是否可以恢复。如果无法恢复,则自动回落到TLS/TCP。

这里有一个比较坑的地方:对于同一个域名,TLS/TCP和QUIC必须使用相同的端口号才能成功开启QUIC。没有什么特殊的原因,提案里面就是这么写的。具体的讨论可以参见Why MUST a server use the same port for HTTP/QUIC?

从上面QUIC的发现过程可以看出,要在网站开启QUIC,主要涉及两个动作:

  1. 配置nginx, 添加alt-svc头部。
  2. 安装和配置QUIC反向代理服务。

配置nginx, 添加alt-svc头部

一行指令搞定:

安装QUIC反向代理服务器caddy

上面我们提到对于同一个域名,TLS/TCP和QUIC必须使用相同的端口号才能成功开启QUIC。然而,caddy服务器的QUIC特性无法单独开启,必须与TLS一起开启,悲剧的是TLS想要使用的TCP 443端口已经被nginx占用了😂

场面虽然有点尴尬,但是我们有docker:将caddy安装到docker中,然后只把本地的UDP 443端口映射到容器中即可。

于是我们创建了一个docker-caddy项目。Dockerfile 10行内搞定:

caddy 服务配置文件/conf/blog.conf:

启动docker:

开启Chrome浏览器QUIC特性

chrome://flags/中找到Experimental QUIC protocol, 设置为Enabled. 重启浏览器生效。

测试QUIC开启状态

重新访问本站https://liudanking.com, 然后在浏览器中打开:chrome://net-internals/#quic。如果你看到了QUIC sessins,则开启成功:

当然,你也可以给Chrome安装一个HTTP/2 and SPDY indicator(An indicator button for HTTP/2, SPDY and QUIC support by each website) 更加直观的观察网站对http/2, QUIC的支持情况。

Golang 获取 goroutine id 完全指南

在Golang中,每个goroutine协程都有一个goroutine id (goid),该goid没有向应用层暴露。但是,在很多场景下,开发者又希望使用goid作为唯一标识,将一个goroutine中的函数层级调用串联起来。比如,希望在一个http handler中将这个请求的每行日志都加上对应的goid以便于对这个请求处理过程进行跟踪和分析。

关于是否应该将goid暴露给应用层已经争论多年。基本上,Golang的开发者都一致认为不应该暴露goid(faq: document why there is no way to get a goroutine ID),主要有以下几点理由:

  1. goroutine设计理念是轻量,鼓励开发者使用多goroutine进行开发,不希望开发者通过goid做goroutine local storage或thread local storage(TLS)的事情;
  2. Golang开发者Brad认为TLS在C/C++实践中也问题多多,比如一些使用TLS的库,thread状态非常容易被非期望线程修改,导致crash.
  3. goroutine并不等价于thread, 开发者可以通过syscall获取thread id,因此根本不需要暴露goid.

官方也一直推荐使用context作为上下文关联的最佳实践。如果你还是想获取goid,下面是我整理的目前已知的所有获取它的方式,希望你想清楚了再使用。

  1. 通过stack信息获取goroutine id.
  2. 通过修改源代码获取goroutine id.
  3. 通过CGo获取goroutine id.
  4. 通过汇编获取goroutine id.
  5. 通过汇编获取goroutine id.

在开始介绍各种方法前,先看一下定义在src/runtime/runtime2.go中保存goroutine状态的g结构:

其中goid int64字段即为当前goroutine的id。

1. 通过stack信息获取goroutine id

原理非常简单,将stack中的文本信息”goroutine 1234″匹配出来。但是这种方式有两个问题:

  1. stack信息的格式随版本更新可能变化,甚至不再提供goroutine id,可靠性差。
  2. 性能较差,调用10000次消耗>50ms。

如果你只是想在个人项目中使用goid,这个方法是可以胜任的。维护和修改成本相对较低,且不需要引入任何第三方依赖。同时建议你就此打住,不要继续往下看了。

2. 通过修改源代码获取goroutine id

既然方法1效率较低,且不可靠,那么我们可以尝试直接修改源代码src/runtime/runtime2.go中添加Goid函数,将goid暴露给应用层:

这个方式能解决法1的两个问题,但是会导致你的程序只能在修改了源代码的机器上才能编译,没有移植性,并且每次go版本升级以后,都需要重新修改源代码,维护成本较高。

3. 通过CGo获取goroutine id

那么有没有性能好,同时不影响移植性,且维护成本低的方法呢?那就是来自Dave Cheney的CGo方式:

文件id.c:

文件id.go:

完整代码参见junk/id.

这种方法的问题在于你需要开启CGo, CGo存在一些缺点,具体可以参见这个大牛的cgo is not Go. 我相信在你绝大部分的工程项目中,你是不希望开启CGo的。

4. 通过汇编获取goroutine id

如果前面三种方法我们都不能接受,有没有第四种方法呢?那就是通过汇编获取goroutine id的方法。原理是:通过getg方法(汇编实现)获取到当前goroutine的g结构地址,根据偏移量计算出成员goid int的地址,然后取出该值即可。

项目goroutine实现了这种方法。需要说明的是,这种方法看似简单,实际上因为每个go版本几乎都会有针对g结构的调整,因此goid int64的偏移并不是固定的,更加复杂的是,go在编译的时候,传递的编译参数也会影响goid int64的偏移值,因此,这个项目的作者花了非常多精力来维护每个go版本g结构偏移的计算,详见hack目录。

这个方法性能好,原理清晰,实际使用上稳定性也不错(我们在部分不太重要的线上业务使用了这种方法)。但是,维护这个库也许真的太累了,最近发现作者将这个库标记为“DEPRECATED”,看来获取goroutine id是条越走越远的不归路😂

5. 通过汇编获取goroutine id

虽然方法4从原理和实际应用上表现都不错,但是毕竟作者弃坑了。回到我们要解决的问题上:我们并不是真的一定要获取到goroutine id,我们只是想获取到goroutine的唯一标识。那么,从这个角度看的话,我们只需要解决goroutine标识唯一性的问题即可。

显然,上面作者也想清楚了这个问题。他新开了一个库go-tls, 这个库实现了goroutine local storage,其中获取goroutine id的方式是:用方法4的汇编获取goroutine的地址,然后自己管理和分配goroutine id。由于它获取到的并不是真正的goroutine id,因此我将之称为goroutine id。其实现的核心代码如下:

  1. 获取g结构地址。
  2. 分配伪goroutine id.

这种方式基本没有什么不能接受的hack实现,从原理上来说也更加安全。但是获取到不是你最开始想要的goroutine id,不知你能否接受😅

小结

获取goroutine id是一条不归路,目前也没有完美的获取它的方式。如果你一定要使用goroutine id,先想清楚你要解决的问题是什么,如果没有必要,建议你不要走上这条不归路。尽早在团队中推广使用context, 越早使用越早脱离对goroutine id的留恋和挣扎。

Credit