移除 macOS 中被企业策略强制安装的 Chrome 插件扩展的方法

公司配发的 Macbook 安装了一些监控软件,毕竟是办公设备,这点倒是无可厚非。但是,其中的 Forcepoint DLP Endpoint 会向日常使用的主力浏览器 Chrome 中安装一个 WebsenEndpoint 的扩展插件。

这个插件的作用不必多说,但是有一个副作用就是让 Chrome 无法自动填写任何网站用户名密码。每天要输入上百次的密码这种体力活是绝对不能忍受的。于是尝试卸载这个插件。但是这个插件是使用企业策略 (Installed by enterprise policy) 强制安装,无法在Chrome中下载。但是可以通过将与之关联的 Profile 删除实现卸载的作用:

但是过两天,Forcepoint 又会把这个插件装回来😂 不过没关系,我们有的是办法。

方法一:通过占用 Chrome 插件文件位置,防止真正插件重新装回

原理是这样的:Chrome 的插件安装在 ~/Library/Application Support/Google/Chrome/Default/Extensions 目录下:

除去 Temp, 每个文件夹对应一个插件。我们希望移除的插件的文件夹名称是 kmofofmjgmakbbmngpgmehldlaaafnjn。由于 macOS 文件夹下,相同名称的文件和文件夹只能存在一个。因此,我们只需要在插件删除以后,在这个目录下新建一个名称为 kmofofmjgmakbbmngpgmehldlaaafnjn 的空文件:

然后,再通过文件系统的 Lock 功能锁定改文件,防止该文件被 Forcpoint 删除:

至此,大功告成,so easy!

方法二:修改 Forcepoint DLP Endpoint 插件安装脚本,禁止强奸 Chrome

法一虽然可以防止Chrome被重新安装插件,但是过几天以后,你会在你的 Profiles 配置下发现,虽然 Chrome 插件安装失败了,但是依然会成功安装这个 Profile:

虽然不会干扰系统的任何功能,但是估计很多强迫症患者看到这个还是挺难受的……从图中我们可以看到,这个插件的路径在 /Library/Application Support/Websense Endpoint/DLP:

其中 setup_chrome_ext.sh 就是强插 Chrome 的脚本:

脚本写得简单粗暴:通过 $PROFILES -I -F "/Library/Application Support/Websense Endpoint/DLP/WebsenseEndpointExtension.config" 向系统强行安装 profile. 要防止其安装插件,把这行代码删除即可。但是,这个文件是属于 admin 用户组的,我们平时使用的账号,即使集合 sudo 也只是 wheel 用户组,是干不过 admin 的,导致我们无法在正常模式下编辑该文件:

因此,我们需要通过 Command (⌘)-R 重启到恢复模式,然后在 /Volumes/mac.os/Library/Application Support/Websense Endpoint/DLP 目录下,将对应代码删除即可。

至此,自己基本满意了🤔

后记

稍微浏览了一下 /Library/Application Support/Websense Endpoint 文件夹,发现其实这个软件不仅强插 Chrome, 还会强插 FireFox:

艾玛……proxy…吓死宝宝了,已经不想改脚本了。直接在恢复模式下删除文件,然后用方法一的方法,建了一个 Lock 文件锁定这个位置。什么,还有 upgrade.sh? 你怎么不上天呢,也一并删除了……突然想起,自己在无所不能的恢复模式下,要不整个软件一起删除了?想到隔天IT技术小哥可能过来找麻烦,就此打住吧😂

Java HTTP 组件库选型看这篇就够了

Java HTTP 组件库选型看这篇就够了

最近项目需要使用 Java 重度调用 HTTP API 接口,于是想着封装一个团队公用的 HTTP client lib. 这个库需要支持以下特性:

  1. 连接池管理,包括连接创建和超时、空闲连接数控制、每个 host 的连接数配置等。基本上,我们想要一个 go HTTP 标准库自带的连接池管理功能。
  2. 域名解析控制。因为调用量会比较大,因此希望在域名解析这一层做一个调用端可控的负载均衡,同时可以对每个服务器 IP 进行失败率统计和健康度检查。
  3. Form/JSON 调用支持良好。
  4. 支持同步和异步调用。

在 Java 生态中,虽然有数不清的 HTTP client lib 组件库,但是大体可以分为这三类:

  1. JDK 自带的 HttpURLConnection 标准库;
  2. Apache HttpComponents HttpClient, 以及基于该库的 wrapper, 如 Unirest.
  3. 非基于 Apache HttpComponents HttpClient, 大量重写应用层代码的 HTTP client 组件库,典型代表是 OkHttp.

HttpURLConnection

使用 HttpURLConnection 发起 HTTP 请求最大的优点是不需要引入额外的依赖,但是使用起来非常繁琐,也缺乏连接池管理、域名机械控制等特性支持。以发起一个 HTTP POST 请求为例:

可以看到,使用 HttpURLConnection 发起 HTTP 请求是比较原始(low level)的,基本上你可以理解为它就是对网络栈传输层(HTTP 一般为 TCP,HTTP over QUIC 是 UDP)进行了一次浅层次的封装,操作原语就是在打开的连接上面写请求 request 与读响应 response. 而且 HttpURLConnection 无法支持 HTTP/2. 显然,官方是知道这些问题的,因此在 Java 9 中,官方在标准库中引入了一个 high level、支持 HTTP/2 的 HttpClient. 这个库的接口封装就非常主流到位了,发起一个简单的 POST 请求:

封装的最大特点是链式调用非常顺滑,支持连接管理等特性。但是这个库只能在 Java 9 及以后的版本使用,Java 9 和 Java 10 并不是 LTS 维护版本,而接下来的 Java 11 LTS 要在2018.09.25发布,应用到线上还需要等待一段时间。因此,虽然挺喜欢这个自带标准库(毕竟可以不引入三方依赖),但当前是无法在生产环境使用的。

Apache HttpComponents HttpClient

Apache HttpComponents HttpClient 的前身是 Apache Commons HttpClient, 但是 Apache Commons HttpClient 已经停止开发,如果你还在使用它,请切换到 Apache HttpComponents HttpClient 上来。

Apache HttpComponents HttpClient 支持的特性非常丰富,完全覆盖我们的需求,使用起来也非常顺手:

对 Client 细致的配置和自定义支持也是非常到位的:

完整示例请参考 ClientConfiguration.

基本上,在 Java 原生标准库不给力的情况下,Apache HttpComponents HttpClient 是最佳的 HTTP Client library 选择。但这个库当前还不支持 HTTP/2,支持 HTTP/2 的版本还处于 beta 阶段(2018.09.23),因此并不适合用于 Android APP 中使用。

OkHttp

由于当前 Apache HttpComponents HttpClient 版本并不支持 HTTP/2, 而 HTTP/2 对于移动客户端而言,无论是从握手延迟、响应延迟,还是资源开销看都有相当吸引力。因此这就给了高层次封装且支持 HTTP/2 的 http client lib 足够的生存空间。其中最典型的要数OkHttp.

OkHttp 接口设计友好,支持 HTTP/2,并且在弱网和无网环境下有自动检测和恢复机制,因此,是当前 Android APP 开发中使用最广泛的 HTTP clilent lib 之一。

另一方面,OkHttp 提供的接口与 Java 9 中 HttpClint 接口比较类似 (严格讲,应该是 Java 9 借鉴了 OkHttp 等开源库的接口设计?),因此,对于喜欢减少依赖,钟情于原生标准库的开发者来说,在 Java 11 中,从 OkHttp 切换到标准库是相对容易的。因此,以 OkHttp 为代表的 http 库以后的使用场景可能会被蚕食一部分。

小结

  • HttpURLConnection 封装层次太低,并且支持特性太少,不建议在项目中使用。除非你的确不想引入第三方 HTTP 依赖(如减少包大小、目标环境不提供三方库支持等)。
  • Java 9 中引入的 HttpClient,封装层次和支持特性都不错。但是因为 Java 版本的原因,应用场景还十分有限,建议观望一段时间再考虑在线上使用。
  • 如果你不需要 HTTP/2特性,Apache HttpComponents HttpClient 是你的最佳选择,比如在服务器之间的 HTTP 调用。否则,请使用 OkHttp, 如 Android 开发。

扩展阅读

Go 中如何准确地判断和识别各种网络错误

Go 自带的网络标准库可能让很多第一次使用它的人感慨,这个库让网络编程的门槛低到了令人发指的地步。然而,封装层次与开发人员的可控性往往是矛盾的。Go 的网络库封装程度算是一个不错的折衷,绝大部分时候,我们只需要调用 Dial, Read, Write Close 几个基本操作就可以了。

但是,网络是复杂的。我们有时候需要细致的处理网络中的各种错误,根据不同的错误进行不同的处理。比如我们遇到一个网络错误时,需要区分这个错误是因为无法解析 host ip, 还是 TCP 无法建立连接,亦或是读写超时。一开始的时候,我们的写法可能是这样的:

    errString := err.Error()
    fmt.Println(errString)
    switch {
    case strings.Contains(errString, "timeout"):
        fmt.Println("Timeout")
    case strings.Contains(errString, "no such host"):
        fmt.Println("Unknown host")
    case strings.Contains(errString, "connection refused"):
        fmt.Println("Connection refused")
    default:
        fmt.Printf("Unknown error:%s", errString)
    }

这种根据错误信息进行字符串匹配进行判断的方法有非常明显的局限性:该错误信息依赖于操作系统,不同的操作系统对于同一错误返回的字符串信息可能是不同的。因此,这种判断网络错误类型的方法是不可靠的。那么有没有一种准确而可靠的判断各种网络错误的方式呢?答案是肯定的。

我们知道在 Go 中,error 是一个内建的 interface 类型:

type error interface {
        Error() string
}

要准确判断不同的错误类型,我们只需要类型断言出其错误类型即可。

在 Go 的网络标准库中,错误类型被统一封装为 net.Errorinterface 类型:

type Error interface {
        error
        Timeout() bool   // Is the error a timeout?
        Temporary() bool // Is the error temporary?
}

net.Error 类型的具体 concrete 类型又被封装为 net.OpError 类型:

type OpError struct {
        // Op is the operation which caused the error, such as
        // "dial", "read" or "write".
        Op string

        // Net is the network type on which this error occurred,
        // such as "tcp" or "udp6".
        Net string

        // For operations involving a remote network connection, like
        // Dial, Read, or Write, Source is the corresponding local
        // network address.
        Source Addr

        // Addr is the network address for which this error occurred.
        // For local operations, like Listen or SetDeadline, Addr is
        // the address of the local endpoint being manipulated.
        // For operations involving a remote network connection, like
        // Dial, Read, or Write, Addr is the remote address of that
        // connection.
        Addr Addr

        // Err is the error that occurred during the operation.
        Err error
}

其中,net.OpError.Err 可能是以下几种类型:

*os.SyscallError 错误比较特殊,与具体操作系统调用有关:

type SyscallError struct {
        Syscall string
        Err     error
}

对于我们关心的网络错误,SyscallError.Err 一般为 sys.Errno 类型,与网络错误相关的常用值有:

  • syscall.ECONNREFUSED
  • syscall.ETIMEDOUT

看到这里,你可能忍不住要吐槽 Go 这种错误嵌套处理了,事实上,官方也意识到了这种错误处理的问题,在 Go 2中,可能会出现新的错误和异常处理方式,可以参见 GopherChina 2018 keynote 点评: RETHINKING ERRORS FOR GO 2.

当前阶段,我们依然要直面这种错误处理方式。为了方便大家理解 Go 网络标准库中处理错误的方式,我们把上面的错误嵌套整理了一张关系图:

明白了网络标准库中处理错误的逻辑,判断和识别各种类型的网络错误就非常简单了:对网络错误进行类型断言。以我们团队主要关心的 DNS 解析错误、TCP 无法建立连接、读写超时为例,判断逻辑可以是这样:

func isCaredNetError(err error) bool {
    netErr, ok := err.(net.Error)
    if !ok {
        return false
    }

    if netErr.Timeout() {
        log.Println("timeout")
        return true
    }

    opErr, ok := netErr.(*net.OpError)
    if !ok {
        return false
    }

    switch t := opErr.Err.(type) {
    case *net.DNSError:
        log.Printf("net.DNSError:%+v", t)
        return true
    case *os.SyscallError:
        log.Printf("os.SyscallError:%+v", t)
        if errno, ok := t.Err.(syscall.Errno); ok {
            switch errno {
            case syscall.ECONNREFUSED:
                log.Println("connect refused")
                return true
            case syscall.ETIMEDOUT:
                log.Println("timeout")
                return true
            }
        }
    }

    return false
}

这种错误判定方式除了能解决最开始提到的可靠性和准确性问题,也具有良好的普适性。即基于 net 的其他标准库,如 net/http 也支持这种错误判断方式。

扩展阅读

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 处理。

扩展阅读

微信小程序文件上传二三事

这段时间陆陆续续上了好几个微信小程序,功能上都会用到文件上传功能(头像上传、证件照上传等)。在APP上传文件到云端的正确姿势中,我们介绍了我们认为安全的上传流程:

即将密钥保存在服务器,客户端每次向服务器申请一个一次性的signature,然后使用该signature作为凭证来上传文件。一般情况下,向阿里云OSS上传内容,又拍云作为灾备。

随着大家安全意识的增强,这种上传流程几乎已经成为标准姿势。但是,把这个流程在应用到微信小程序却有很多细节需要调整。这里把踩过的坑记录一下,希望能让有需要的同学少走弯路。

微信小程序无法直接读取文件内容进行上传

在我们第一版的上传流程方案中,我们的cds 签名发放服务只实现了阿里云 PutObject 接口的signature发放. PutObject 上传是直接将需要上传的内容以二进流的方式 PUT 到云储存。

但是,微信小程序提供的文件上传API wx.uploadFile 要求文件通过 filePath 提供:

另一方面,微信小程序的 JS API 当前还比较封闭,无法根据 filePath 读取到文件内容,因此也无法通过 wx.request 直接发起网络请求的方式来实现文件上传。

考虑到 wx.uploadFile 本质上是一个 multipart/form-data 网络请求的封装,因此我们只需要实现一个与之对应的签名发放方式接口。阿里云OSS对应的上传接口是 PostObject, 又拍云对应的是其 FORM API. 以阿里云OSS为例,cds 服务生成signature 代码如下:

func GetDefaultOSSPolicyBase64Str(bucket, key string) string {
    policy := map[string]interface{}{
        "expiration": time.Now().AddDate(3, 0, 0).Format("2006-01-02T15:04:05.999Z"),
        "conditions": []interface{}{
            map[string]string{
                "bucket": bucket,
            },
            []string{"starts-with", "$key", key},
        },
    }
    data, _ := json.Marshal(&policy)
    return base64.StdEncoding.EncodeToString(data)
}

func GetOSSPostSignature(secret string, policyBase64 string) string {
    h := hmac.New(sha1.New, []byte(secret))
    io.WriteString(h, policyBase64)
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

小程序端代码如下:

//使用说明
/**
 * 1、引入该文件:const uploadFile = require('../../common/uploadAliyun.js');
 * 2、调用如下:
 * uploadImg: function () {
        const params = {
            _success: this._success
        }
        uploadFile.chooseImg(params);
    },
    _success: function(imgUrl){
        this.setData({
            cover_url: imgUrl,
        })
    },
*/

const uploadFile = {
    _fail: function(desc) {
        wx.showToast({
            icon: "none",
            title: desc
        })
    },
    _success: function() {},
    chooseImg: function(sendData) {
        //先存储传递过来的回调函数
        this._success = sendData._success;
        var that = this;
        wx.chooseImage({
            count: 1,
            sizeType: ['original', 'compressed'],
            sourceType: ["album", "carmera"],
            success: function (res) {
                that.getSign(res.tempFilePaths[0]);
            },
            fail: function (err) {
                wx.showToast({
                    icon: "none",
                    title: "选择图片失败" + err
                })
            }
        })
    },
    //获取阿里上传图片签名
    getSign: function (path) {
        var that = this;
        wx.request({
            url: 'https://somewhere/v2/cds/apply_upload_signature',
            method: 'POST',
            data: {
                "content_type": "image/jpeg",
                "signature_type": "oss_post",
                "business": "xiaochengxu",
                "file_ext": '.jpeg',
                "count": '1'
            },
            success: function (res) {
                let getData = res.data.data[0];
                that.startUpload(getData, path);
            },
            fail: function (err) {
                that._fail("获取签名失败" + JSON.stringify(err))
            }
        })
    },
    //拿到签名后开始上传
    startUpload: function (getData, path) {
        var that = this;
        this.uploadAliYun({
            filePath: path,
            dir: 'wxImg/',
            access_key_id: getData.oss_ext_param.access_key_id,
            policy_base64: getData.oss_ext_param.policy_base64,
            signature: getData.signature,
            upload_url: getData.upload_url,
            object_key: getData.oss_ext_param.object_key,
            content_url: getData.content_url.origin 
        })
    },
    uploadAliYun: function(params) {
        var that = this;
        // if (!params.filePath || params.filePath.length < 9) {
        if (!params.filePath) {
            wx.showModal({
                title: '图片错误',
                content: '请重试',
                showCancel: false,
            })
            return;
        }
        const aliyunFileKey = params.dir + params.filePath.replace('wxfile://', '');

        const aliyunServerURL = params.upload_url;
        const accessid = params.access_key_id;
        const policyBase64 = params.policy_base64;
        const signature = params.signature;
        wx.uploadFile({
            url: aliyunServerURL,
            filePath: params.filePath,
            name: 'file',
            formData: {
                'key': params.object_key,
                'policy': policyBase64,
                'OSSAccessKeyId': accessid,
                'Signature': signature
            },
            success: function (res) {
                if (res.statusCode != 204) {
                    that._fail("上传图片失败");
                    return;
                }
                that._success(params.content_url);
            },
            fail: function (err) {
                that._fail(JSON.stringify(err));
            },
        })
    }
}


module.exports = uploadFile;

使用阿里云OSS域名上传失败

解决签名问题后,发现使用阿里云OSS提供的上传域名无法上传成功,在微信后台尝试添加合法域名的时候,惊奇的发现阿里云OSS的域名直接被微信小程序封禁了:

显然是两个神仙在打架,作为草民只能见招拆招。解决办法就是在阿里云OSS -> bucket -> 域名管理 绑定用户域名:

此外,由于微信小程序已经升级为uploadFile的链接必须是https, 因此还需要在绑定用户域名后设置 证书托管

他山之石,可以攻玉

既然微信能够封禁用阿里云OSS的上传域名,那么微信也可以封禁你自定义的域名。根据以往经验(对天发誓,我们不是有意为之,我们也是受害者……),微信封禁域名一般都是一锅端,即发现一个子域名存在违规内容,那么整个域名都会被封禁。因此,一方面要从技术角度对上传的内容及时检查是否合规(如黄图扫描),另一方面提前做好域名规划,将业务接口域名与自定义的文件上传域名分开,这样即使上传域名被一锅端了,不至于是业务完全不可用。

一次非典型性 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性能测试数据,代码也还没有开源。因此,猴急的同学可能还需要等一段时间。

100行代码实现基于 QUIC 的 http 代理

本站开启支持 QUIC 的方法与配置后,主观感觉从国内访问快了很多。看了一下Chrome的timing, 大部分建立连接都能够做到0-RTT:

既然这样,顺手实现一个基于QUIC的http代理,把平时查资料时使用的网络也顺带加速一下。(对了,前两天看到Google发布了Outline, 看来这项运动从来都不缺少运动员哪……)

http 代理原理

http 代理处理http和https请求的方式有所不同。对于http请求:

  1. 浏览器与代理服务器建立TCP连接后,将http请求发送给代理服务器。
  2. 代理服务器将http请求发送给目标服务器。
  3. 代理服务器获取到相应结果以后,将结果发送给浏览器。

这里有一个细节需要注意,浏览器向代理服务器发送的http请求URI与直接访问有所不同。

浏览器直接访问 GET http://www.yahoo.com 的http请求格式为:

GET / HTTP/1.1
User-Agent: Quic-Proxy
...

而向代理服务器发送的http请求格式为:

GET http://www.yahoo.com HTTP/1.1
User-Agent: Quic-Proxy
...

也就是浏览器想代理服务器发送的http请求URI中包含了scheme和host,目的是为了让代理服务器知道这个代理请求要访问的目标服务器地址。

对于https请求,一般是通过CONNECT建立隧道:

  1. 浏览器向代理服务器建立TCP连接,发送CONNECT请求。
  2. 代理服务器根据CONNECT请求中包含的host信息,向目标服务器建立TCP连接,然后向浏览器返回200连接成功的响应。
  3. 这时代理服务器同时维持着连接浏览器和目标服务器的TCP连接。
  4. 从浏览器的角度看,相当于建立了一条直连目标服务器的TCP隧道。然后直接在该隧道上进行TLS握手,发送http请求即可实现访问目标服务器的目的。

QUIC Proxy的设计与实现

QUIC Proxy 部署结构图

QUIC Proxy的部署结构与上面http代理原理稍微有所不同。主要区别是增加了qpclient。主要原因是应用程序与代理服务器支架的请求是明文传输(http请求代理是全明文,https请求代理时的CONNECT头会泄露目标服务器信息)。我们是要隐私的人(虽然小扎可能并不care),因此,在应用程序与qpserver之间加了一个qpclient,之间使用QUIC作为传输层。

实现

QUIC Proxy使用Go实现,猴急的同学可以直接到github看源码:Quic Proxy, a http/https proxy using QUIC as transport layer.

代码比较简单,基于标准库的http.Server根据http代理的原理进行了一点http请求的修改。然后,因为qpclientqpserver之间使用QUIC作为transport,而QUIC上的每一个connection都是可以多路复用(multiplexing)的,因此,对于qpserver需要自己实现一个传入http.Server的listener:

type QuicListener struct {
    quic.Listener
    chAcceptConn chan *AcceptConn
}

type AcceptConn struct {
    conn net.Conn
    err  error
}

func NewQuicListener(l quic.Listener) *QuicListener {
    ql := &QuicListener{
        Listener:     l,
        chAcceptConn: make(chan *AcceptConn, 1),
    }
    go ql.doAccept()
    return ql
}

func (ql *QuicListener) doAccept() {
    for {
        sess, err := ql.Listener.Accept()
        if err != nil {
            log.Error("accept session failed:%v", err)
            continue
        }
        log.Info("accept a session")

        go func(sess quic.Session) {
            for {
                stream, err := sess.AcceptStream()
                if err != nil {
                    log.Error("accept stream failed:%v", err)
                    sess.Close(err)
                    return
                }
                log.Info("accept stream %v", stream.StreamID())
                ql.chAcceptConn <- &AcceptConn{
                    conn: &QuicStream{sess: sess, Stream: stream},
                    err:  nil,
                }
            }
        }(sess)
    }
}

func (ql *QuicListener) Accept() (net.Conn, error) {
    ac := <-ql.chAcceptConn
    return ac.conn, ac.err
}

同样的,qpclientqpserver建立连接也需要考虑到多路复用的问题,实现实现一个基于QUIC的dialer:

type QuicStream struct {
    sess quic.Session
    quic.Stream
}

func (qs *QuicStream) LocalAddr() net.Addr {
    return qs.sess.LocalAddr()
}

func (qs *QuicStream) RemoteAddr() net.Addr {
    return qs.sess.RemoteAddr()
}

type QuicDialer struct {
    skipCertVerify bool
    sess           quic.Session
    sync.Mutex
}

func NewQuicDialer(skipCertVerify bool) *QuicDialer {
    return &QuicDialer{
        skipCertVerify: skipCertVerify,
    }
}

func (qd *QuicDialer) Dial(network, addr string) (net.Conn, error) {
    qd.Lock()
    defer qd.Unlock()

    if qd.sess == nil {
        sess, err := quic.DialAddr(addr, &tls.Config{InsecureSkipVerify: qd.skipCertVerify}, nil)
        if err != nil {
            log.Error("dial session failed:%v", err)
            return nil, err
        }
        qd.sess = sess
    }

    stream, err := qd.sess.OpenStreamSync()
    if err != nil {
        log.Info("[1/2] open stream from session no success:%v, try to open new session", err)
        qd.sess.Close(err)
        sess, err := quic.DialAddr(addr, &tls.Config{InsecureSkipVerify: true}, nil)
        if err != nil {
            log.Error("[2/2] dial new session failed:%v", err)
            return nil, err
        }
        qd.sess = sess

        stream, err = qd.sess.OpenStreamSync()
        if err != nil {
            log.Error("[2/2] open stream from new session failed:%v", err)
            return nil, err
        }
        log.Info("[2/2] open stream from new session OK")
    }

    log.Info("addr:%s, stream_id:%v", addr, stream.StreamID())
    return &QuicStream{sess: qd.sess, Stream: stream}, nil
}

好吧,我承认实现代码似乎在200行左右……但是,我们实现了一个client和一个server, 平均下来基本控制在100行左右,对吧……(😂逃……)

部署

:需要golang版本 >= 1.9

1. 在远程服务器上安装 qpserver

go get -u github.com/liudanking/quic-proxy/qpserver

2. 启动qpserver:

qpserver -v -l :3443 -cert YOUR_CERT_FILA_PATH -key YOUR_KEY_FILE_PATH

3. 在本地安装 qpclient

go get -u github.com/liudanking/quic-proxy/qpclient

4. 启动 qpclient:

qpclient -v -k -proxy http://YOUR_REMOTE_SERVER:3443 -l 127.0.0.1:18080

5. 设置应用程序代理:

以 Chrome with SwitchyOmega 为例:

Enjoy!

本站开启支持 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的支持情况。