前言
并不是所有人都是在使用像 SM.MS , Imgur 之类专为提供照片存储服务的图床, 多数人会考虑到安全性,隐私性,容量,价格(主要是价格)而去选择一些其他服务用来做图床(这里不考虑自建的情况), 例如 GitHub 仓库作为图床(比如我), 但是像这样的方式往往会有很多缺点, 其中最重要的
国内访问速度慢
缓存设置不合适
GitHub的usercontent 默认缓存控制模式为Cache-Control: max-age=300
, 一张也许永远都不会变(或是极少会出现变化,比如ImgBot做图片优化时)的一张图片,在本地只有5分钟的有效期, 加载超慢的速度+需要频繁刷新=要命(嫌弃脸)
额外的隐私问题
使用公开仓库, 如果不小心上传了涉及隐私的图片怎么办?Git里的删除操作git rm
并没有真正的删除而是一种修改,如果查找历史的话还是可以找到那些图片, 而GitHub官方提供了 从仓库中删除敏感数据 的方案,尽管如此依旧相当麻烦
然而这几个缺点使用一个简单的中间代理就可以轻松解决,这里我们可以使用简短的代码完成这个代理
如果你想直接查看效果, 那么就去感受一下本博客站的图片加载速度即可
如果你只是想拿结果来用,请自行跳转至部署部分
代理基本结构
开始一个项目并初始化module grpfih
,即Github Reverse Proxy For Image Host , 新建main.go
1 2 go mod init grpfih touch main.go
反向代理本身也是一个HTTP Server , 因此我们的主函数应当是这样的结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package mainvar ( addr = ":8080" ) func main () { http.HandleFunc("/" , func (rw http.ResponseWriter, r *http.Request) { start := time.Now() defer func () { log.Println(r.Method, r.URL, time.Since(start)) }() if r.Method != http.MethodGet { http.Error(rw, "Method Not Allowed" , http.StatusMethodNotAllowed) return } rproxy.ServeHTTP(rw, r) }) log.Fatal(http.ListenAndServe(addr, nil )) }
这里的程序将会在本地监听0.0.0.0:8080
, 我们添加了根目录/
的路由以处理所有的请求, 这个Handler
记录该路由访问时的执行所用的时间
如果访问方法不是GET
,将返回405错误 // RFC 7231, 6.5.5
将请求交给我们即将创建的rproxy
处理
Go的httputil
包提供了我们想要的ReverseProxy
, 并且提供了一个好用的函数 NewSingleHostReverseProxy , 这个函数提供了基本的重写请求的代码,然而这里我们并不能直接使用它, 因为它并不会重写请求头部的Host
字段,这会导致HTTP请求并不会重发到我们想要的目的地(毕竟是Single Host)
NewSingleHostReverseProxy does not rewrite the Host header. To rewrite Host headers, use ReverseProxy directly with a custom Director policy.
所以我们直接复制过来想要的代码并进行修改,包括这个函数依赖的另外两个函数joinURLPath
, singleJoiningSlash
(我们直接复制粘贴不考虑细节,代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 func NewReverseProxy (target *url.URL) *httputil.ReverseProxy { targetQuery := target.RawQuery director := func (req *http.Request) { req.URL.Scheme = target.Scheme req.URL.Host = target.Host req.Host = target.Host req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL) if targetQuery == "" || req.URL.RawQuery == "" { req.URL.RawQuery = targetQuery + req.URL.RawQuery } else { req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery } if _, ok := req.Header["User-Agent" ]; !ok { req.Header.Set("User-Agent" , "" ) } } return &httputil.ReverseProxy{Director: director} } func joinURLPath (a, b *url.URL) (path, rawpath string ) { if a.RawPath == "" && b.RawPath == "" { return singleJoiningSlash(a.Path, b.Path), "" } apath := a.EscapedPath() bpath := b.EscapedPath() aslash := strings.HasSuffix(apath, "/" ) bslash := strings.HasPrefix(bpath, "/" ) switch { case aslash && bslash: return a.Path + b.Path[1 :], apath + bpath[1 :] case !aslash && !bslash: return a.Path + "/" + b.Path, apath + "/" + bpath } return a.Path + b.Path, apath + bpath } func singleJoiningSlash (a, b string ) string { aslash := strings.HasSuffix(a, "/" ) bslash := strings.HasPrefix(b, "/" ) switch { case aslash && bslash: return a + b[1 :] case !aslash && !bslash: return a + "/" + b } return a + b }
可以直接忽略掉后面两个附带的辅助函数,因为这并不是我们这个程序的重点
上面代码只把NewSingleHostReverseProxy
名字改成了NewReverseProxy
, 并添加了一行重写Host
的代码,这样创建反代的函数完成了,我们现在可以在主函数中完成rproxy
的创建
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 package mainvar ( addr = os.Getenv("LISTEN_ADDR" ) baseUrl = os.Getenv("BASE_URL" ) ) func init () { if addr == "" { addr = "127.0.0.1:8080" } if baseUrl == "" { baseUrl = "https://raw.githubusercontent.com/NobeKanai/" } } func main () { target, err := url.Parse(baseUrl) if err != nil { log.Fatal(err) } rproxy := NewReverseProxy(target) http.HandleFunc("/" , func (rw http.ResponseWriter, r *http.Request) { start := time.Now() defer func () { log.Println(r.Method, r.URL, time.Since(start)) }() if r.Method != http.MethodGet { http.Error(rw, "Method Not Allowed" , http.StatusMethodNotAllowed) return } rproxy.ServeHTTP(rw, r) }) log.Fatal(http.ListenAndServe(addr, nil )) }
这样我们最基本的代理结构就已经完成了,可以尝试执行
1 2 export BASE_URL=https://raw.githubusercontent.com/gruns/icecream/master/go run .
Windows系统执行
1 2 set BASE_URL=https://raw.githubusercontent.com/gruns/icecream/master/go run .
访问 localhost:8080/logo.svg 可以看到效果,相当于直接访问 https://raw.githubusercontent.com/gruns/icecream/master/logo.svg
打印日志(传输3kb花费1s左右)
1 2 2021/04/14 16:09:59 GET /logo.svg 1.0865429s 2021/04/14 16:12:24 GET /logo.svg 1.3626978s
优化访问 - Reverse Proxy Over Proxy
为了加速我们反代的访问速度, 我们可能会考虑使用另外一个代理(不多说干嘛的了), 我们可直接设置httputil.ReverseProxy
的Transport
值来直接配置代理(httputil.ReverseProxy
并不会遵循http(s)_proxy 环境变量, 需要自己手动设置), 例如
1 2 3 rproxy.Transport = &http.Transport{ Proxy: http.ProxyURL("http://localhost:7890" ), }
所以我们这里增加配置项proxyUrl
, 从环境中读取HTTPS_PROXY /HTTP_PROXY 变量,如果存在非空值则设置我们的rproxy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 var ( proxyAddr = os.Getenv("HTTPS_PROXY" ) addr = os.Getenv("LISTEN_ADDR" ) baseUrl = os.Getenv("BASE_URL" ) ) func init () { if proxyAddr == "" { if proxyAddr = os.Getenv("HTTP_PROXY" ); proxyAddr == "" { log.Println("No proxy found." ) } } } func main () { target, err := url.Parse(baseUrl) if err != nil { log.Fatal(err) } rproxy := NewReverseProxy(target) if proxyAddr != "" { proxyUrl, err := url.Parse(proxyAddr) if err != nil { log.Fatalf("error when parse proxy address: %v" , err) } log.Println("set up proxy:" , proxyAddr) rproxy.Transport = &http.Transport{ Proxy: http.ProxyURL(proxyUrl), } } }
执行
1 2 export HTTPS_PROXY=http://localhost:7890 go run .
输出
1 2 3 4 5 6 7 2021/04/14 16:31:05 set up proxy: http://localhost:7890 2021/04/14 16:31:07 GET /logo.svg 210.4739ms 2021/04/14 16:31:10 GET /logo.svg 111.6754ms 2021/04/14 16:31:10 GET /logo.svg 106.579ms 2021/04/14 16:31:11 GET /logo.svg 152.2702ms 2021/04/14 16:31:11 GET /logo.svg 121.9425ms 2021/04/14 16:31:12 GET /logo.svg 117.2133ms
平均150ms左右,可以看到访问速度快了很多, 具体速度可能取决于你使用的代理和网络情况
优化缓存
这一部分我们优化GitHub返回的请求的头部信息, 其中包括Cache-Control
, Expires
等控制缓存的头部和一些无用的头部
httputil.ReverseProxy.ModifyResponse
用于修改返回的响应
1 ModifyResponse func (*http.Response) error
为了方便我直接将我们要创建的ModifyResponse
写在NewReverseProxy
里
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func NewReverseProxy (target *url.URL) *httputil.ReverseProxy { respFilter := func (rw *http.Response) error { rw.Header.Set("Cache-Control" , "public, max-age=604800, immutable" ) rw.Header.Del("Expires" ) rw.Header.Del("Vary" ) rw.Header.Del("Via" ) rw.Header.Del("X-Fastly-Request-Id" ) rw.Header.Del("X-Github-Request-Id" ) rw.Header.Del("X-Served-By" ) rw.Header.Del("X-Timer" ) rw.Header.Del("X-Cache" ) rw.Header.Del("X-Cache-Hits" ) return nil } return &httputil.ReverseProxy{Director: director, ModifyResponse: respFilter} }
这里的ModifyResponse
即respFilter
执行了
判断返回值类型, 如果不是image/xxx 则直接返回前面定义过的errNotImage
错误,而这个错误会在errHandler
里进行处理(返回404) 我们后面交给handler处理
重置Cache-Control
为public, max-age=604800, immutable
, 604800相当于7天,具体含义请参考这里
删除了其他不必要的头部
另外可以在Handler里简单限制请求的文件类型
1 2 3 4 5 6 7 8 9 10 11 12 13 http.HandleFunc("/" , func (rw http.ResponseWriter, r *http.Request) { ext := filepath.Ext(r.RequestURI)[1 :] if t := filetype.GetType(ext); ext != "svg" && t.MIME.Type != "image" { http.Error(rw, "404 Not Found" , http.StatusNotFound) return } rproxy.ServeHTTP(rw, r) })
filetype
模块来自第三方库 github.com/h2non/filetype
你可以通过执行
1 go get github.com/h2non/filetype
来安装次模块
这里我们可以执行测试一下,具体方式和上面一样,我这里不重复了, 通过观察响应头可以看到相应的头部被修改/删除掉了
修改前:
修改后:
访问私有仓库
如果你曾自行搭建过GitHub图床并使用过像PicGo 这样的工具, 那么你一定知道如何去申请token
, 这里将不再介绍, 不过有一点需要注意的是, 你的token
一定要允许访问你的图床仓库(私有)
我们要做的是在请求头前面增加这样一个头部字段
1 Authorization : token <your-token>
我们只需要简单的增加几行配置代码即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 var ( proxyAddr = os.Getenv("HTTPS_PROXY" ) addr = os.Getenv("LISTEN_ADDR" ) token = os.Getenv("GITHUB_TOKEN" ) baseUrl = os.Getenv("BASE_URL" ) ) func NewReverseProxy (target *url.URL, token string ) *httputil.ReverseProxy { targetQuery := target.RawQuery director := func (req *http.Request) { req.URL.Scheme = target.Scheme req.URL.Host = target.Host req.Host = target.Host req.URL.Path, req.URL.RawPath = joinURLPath(target, req.URL) if token != "" { req.Header.Set("Authorization" , fmt.Sprintf("token %s" , token)) } } }
注意NewReverseProxy
新增了一个参数token string
, 我们调用的时候传入即可(这里看起来没必要,但是为了处理清楚依赖关系和方便后面扩展还是推荐这么做, 如果想的话, 设置proxy的部分也可以转移到这里)
很好我们这个时候可以执行
1 2 export GITHUB_TOEKN=your-token-herego run .
然后试着访问你的私有仓库内的图片吧
另外为了添加一些日志输出和处理一些可能会浪费资源的请求,最终的main函数为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 func main () { target, err := url.Parse(baseUrl) if err != nil { log.Fatal(err) } rproxy := NewReverseProxy(target, token) if proxyAddr != "" { proxyUrl, err := url.Parse(proxyAddr) if err != nil { log.Fatalf("error when parse proxy address: %v" , err) } log.Println("set up proxy:" , proxyAddr) rproxy.Transport = &http.Transport{ Proxy: http.ProxyURL(proxyUrl), } } http.HandleFunc("/" , func (rw http.ResponseWriter, r *http.Request) { start := time.Now() defer func () { log.Println(r.Method, r.URL, time.Since(start)) }() if r.Method != http.MethodGet { http.Error(rw, "Method Not Allowed" , http.StatusMethodNotAllowed) return } ext := filepath.Ext(r.RequestURI)[1 :] if t := filetype.GetType(ext); ext != "svg" && t.MIME.Type != "image" { http.Error(rw, "404 Not Found" , http.StatusNotFound) return } rproxy.ServeHTTP(rw, r) }) http.HandleFunc("/favicon.ico" , func (rw http.ResponseWriter, r *http.Request) { rw.WriteHeader(http.StatusNotFound) }) log.Println("start server at" , addr) log.Println("set up reverse proxy:" , baseUrl) if token != "" { log.Println("set up github token" ) } log.Fatal(http.ListenAndServe(addr, nil )) }
部署和Nginx配置
如何部署或者如何去配置取决于个人, 这里我使用的是Docker
部署, 如果不熟悉你可以选择自己的方式部署
另外非常建议将此反代程序用Nginx之类的软件反代(疯狂套娃)以提供
Docker部署
你完全可以直接使用我已经构建的镜像运行
1 2 docker pull nobekanai/grpfih:latest docker run -d -it -e BASE_URL= -e GITHUB_TOKEN= -e HTTPS_PROXY= -p 8080:8080 nobekanai/grpfih
如果你需要构建你自己的
这里我提供自己使用的Dockerfile
作为示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 FROM golang:1.16 -alpine AS builderWORKDIR /src COPY go.mod go.sum ./ RUN export GOPROXY=https://goproxy.cn && go mod download COPY . . RUN go build -ldflags="-w -s" -o /out/server . FROM alpineRUN sed -i "s/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g" /etc/apk/repositories \ && apk add --no-cache tzdata ENV TZ=Asia/ShanghaiCOPY --from=builder /out/server /server ENV LISTEN_ADDR=:8080 EXPOSE 8080 USER nobodyENTRYPOINT [ "/server" ]
你可以直接命令行执行,也可以使用docker-compose执行
这里同时给出docker-compose.yml 配置示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 version: "3" services: grpfih: image: nobekanai/grpfih:latest container_name: grpfih restart: unless-stopped environment: - HTTP_PROXY=http://host.docker.internal:7890 - HTTPS_PROXY=http://host.docker.internal:7890 - GITHUB_TOKEN=${GITHUB_TOKEN} - BASE_URL=https://raw.githubusercontent.com/NobeKanai/<some-repo>/main/ ports: - 127.0 .0 .1 :8080:8080
当你配置好环境变量,你只需要执行
就可以部署一个干净轻量的反代服务(占用内存约2-5MB)
Nginx配置
配置前你需要确认Grpfih所在位置/地址
如果你配置网络, 例如Nginx和Grpfih在同一个网络内,那么可以通过域名grpfih直接连接, 尝试进入nginx容器执行
如果你得到了响应那么证明nginx和grpfih是联通的
这里只提供关键部分nginx配置作为参考
1 2 3 4 5 6 7 8 9 10 11 12 13 14 proxy_cache_path /data/nginx/cache levels=1 :2 keys_zone=STATIC:10m inactive=7d max_size=1g ; location / { proxy_pass http://grpfih:8080; proxy_buffering on ; proxy_cache STATIC; proxy_cache_valid 200 7d ; proxy_cache_key "$request_uri " ; proxy_cache_background_update on ; proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504; }
至此,整个反代服务就完成了,具体效果你可以通过观察本博客的图片加载来作为参考。
结语
本篇博客涉及到很多配置相关以及部分代码编写方式都是临时查阅资料根据自己的理解得来的, 如果有哪里不合适或者你认为更好的地方, 请通过评论/邮箱等方式告诉我, 非常感谢
FAQ
为什么不用像 Chevereto 这样的自建图床服务?
目前能找到最好的自建图床服务就是Chevereto,但是它并不支持Postgresql (也许只是我不知道)我并不打算再花费额外100MB的RAM开启一个MariaDB 容器,其次Docker容器内置Nginx, 我个人服务器已经运行Nginx容器,额外的内存占用和个人对php
的偏见让我选择拒绝, 最后官方提供的Docker容器没有提供对arm 架构的设备的支持
绝大多数功能只是用Nginx配置就能完成了吧,为什么非要写出一个这样的程序?
没错,除了Reverse Proxy Over Proxy 部分都可以简单的使用Nginx配置,然而我做这个程序的目的就是这一个功能而已, 其它的都是顺便 做的, 另外这个程序需要自己写的部分不过一二十行代码,就当是写来玩玩也不为过,顺便体验一下Golang
轻量高性能的特性带来的奇妙快感