
Golang Write a Cache Proxy
写了一个简单的缓存代理。
起因
我回到了坚果云有段时间了,现在的工作还是和 Linux 有关的比较多,其中很大的一部分就是和发行版打交道。
比较头疼的有三个问题:
- 经常会用 vagrant 启动多个虚拟机,八个虚拟机同时做初始化,软件包都从网络安装给办公室不多的带宽带来了不少压力
- 有的发行版,比如说 CentOS 7,结束支持后有的拉取过的包从哪里都找不到了,缓存的rpm之间版本依赖出现问题
- 尝试了 Nexus OSS Repository,什么都好,就是太占内存了
- 尝试了自建源定期同步,但是很大一部分包时不需要的,比如说我不需要在我的服务器上安装 0AD(
这个游戏还不错)。而且定期意味着很多时候不是最新的。
除此之外还遇到了一些奇怪的情况,比如说有一些镜像会忽然只给100KB/s的速度,有一些镜像一定要验证是在浏览器验证后才能下载。
分析、简化需求
根据上面三个头疼的地方,现在我的目标是找一个或者写一个代理:
- 对同一个文件,并发的请求、相近时间的请求合并为同一个
- 本地存在的文件,直接从本地发送。但是留一个口子用来更新文件
- 有的时候单一上游很慢,可以从多个上游选择一个下载速度最快的,而不是返回最快、延迟最小的
- 内存占用小
对于 apt 和 dnf 这两个包管理来说,都存在一个中心的文件表明当前时间点各个数据库文件的哈希, 所以按照这一个文件下载数据库,解析出的 deb / rpm 依赖总是一致的。而 alpine 的 apk、archlinux 的 pacman 的数据库本身只有一个文件,所以不存在一致性问题。
简单说,对于系统包管理的代理,我只要正确实现文件的代理、下载、缓存更新就足够了,不需要单独实现 apt / dnf 类型的仓库。 Nexus OSS Repository 过于复杂了。
对于 Nexus OSS Repository 的 Group 类型,我可以之后编写 hook 函数,或者通过定时任务来实现。
实现本身很简单
实现本身很简单:
- 收到请求后检查内存中是否有正在下载的缓存,如果存在正在下载的请求,直接从内存中返回内容。
- 检查本地文件是否存在,根据配置文件是否需要检查文件更新。
- 发起请求,下载文件,并流式写回给客户端。
- 发起请求时,同时向多个上游发起请求,读取一段内容,先返回状态码 200 OK 并读取头一段内容的赢得返回给客户端的机会,其他的请求通过 context cancel 立刻终止。
- 如果本地存在文件,但文件需要检查更新,那么会附带上 If-Modified-Since 头。如果所有请求都返回了 304 Not Modified,那么可以断定文件没有被修改,否则返回唯一的一个 200 响应。
- 请求结束后,把内容写回文件,更新 mtime。
遇到的坑
上游中断
正常情况下上游中断时,系统包管理会收到一个 tcp reset,系统包管理就知道要再试一次。但是这个时候 200 OK 已经写给客户端了,Handler Function 直接返回也好怎么样也好,请求都不会返回异常。有的发行版会校验文件长度和哈希,有的会校验签名,但是都会有一个问题是如果正常关闭连接,他会在检查本地文件时才会报错,而且如果cache-proxy套cache-proxy就很可能会缓存一个错误的文件。
这个时候需要 cache-proxy 向客户端也发送 tcp reset。查找了一些资料以后,找到一个办法是 socket option: SO_LINGER。
对于 c 来说,大约是设置这样的一个 socket option:
int ret = 0;
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
if ((ret = setsockopt(s,
SOL_SOCKET,
SO_LINGER,
&so_linger,
sizeof so_linger
)) != 0) {
perror("setsockopt failed");
}
对于 go 来说,需要把 http.HandlerFunc 接受的参数 http.ResponseWriter 通过 interface http.Hijacker { Hijack() (net.Conn, ..., error) }
取得 net.Conn,并最终得到 *net.TCPConn,然后设置 Linger
hijacker, ok := responseWriter.(http.Hijacker)
if !ok {
logger.Warn("response writer is not a hijacker. failed to set lingering")
return
}
conn, _, err := hijacker.Hijack()
if err != nil {
logger.With("error", err).Warn("hijack failed. failed to set lingering")
return
}
defer conn.Close()
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
logger.With("error", err).Warn("connection is not a *net.TCPConn. failed to set lingering")
return
}
if err := tcpConn.SetLinger(0); err != nil {
logger.With("error", err).Warn("failed to set lingering")
return
}
当 socket 被 close 时,Linux 会等待这里设置的 0 秒,也就是不等待,并直接发送一个 tcp reset。从而告知包管理:我也遇到问题了,你赶紧重新发一个请求吧。