前言

并不是所有人都是在使用像 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 main

var (
addr = ":8080"
)

func main() {
// start a new http server
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) // rproxy即我们即将创建的反向代理
})

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
// NewReverseProxy create a new reverse proxy
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 {
// explicitly disable User-Agent so it's not set to default value
req.Header.Set("User-Agent", "")
}
}

return &httputil.ReverseProxy{Director: director}
}

// joinURLPath 实现两个url.URL的拼接 可以忽略这个函数
func joinURLPath(a, b *url.URL) (path, rawpath string) {
if a.RawPath == "" && b.RawPath == "" {
return singleJoiningSlash(a.Path, b.Path), ""
}
// Same as singleJoiningSlash, but uses EscapedPath to determine
// whether a slash should be added
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
}

// singleJoiningSlash 删除重复"/"
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 main

var (
addr = os.Getenv("LISTEN_ADDR") // 使用环境变量替代常量
baseUrl = os.Getenv("BASE_URL")
)

func init() {
// 初始化addr和baseUrl的默认值
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)
}

// set up reverse proxy
rproxy := NewReverseProxy(target)

// start a new http server
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) // rproxy即我们即将创建的反向代理
})

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

image-20210414161013505

打印日志(传输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.ReverseProxyTransport值来直接配置代理(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)
}

// set up reverse proxy
rproxy := NewReverseProxy(target)

// set up proxy for reverse proxy
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 # 设置你自己的代理地址 windows系统 使用set语句
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
// NewReverseProxy create a new reverse proxy
func NewReverseProxy(target *url.URL) *httputil.ReverseProxy {
// ...

respFilter := func(rw *http.Response) error {
// remove unused headers & optimize cache control
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}
}

这里的ModifyResponserespFilter执行了

  • 判断返回值类型, 如果不是image/xxx则直接返回前面定义过的errNotImage错误,而这个错误会在errHandler里进行处理(返回404) 我们后面交给handler处理
  • 重置Cache-Controlpublic, max-age=604800, immutable, 604800相当于7天,具体含义请参考这里
  • 删除了其他不必要的头部

另外可以在Handler里简单限制请求的文件类型

1
2
3
4
5
6
7
8
9
10
11
12
13
// start a new http server
http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
// ...

// if req is not for image, just return 404
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")
)


// NewReverseProxy create a new reverse proxy
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)

// auth header
if token != "" {
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
}

// ...
}

// ...
}

注意NewReverseProxy新增了一个参数token string, 我们调用的时候传入即可(这里看起来没必要,但是为了处理清楚依赖关系和方便后面扩展还是推荐这么做, 如果想的话, 设置proxy的部分也可以转移到这里)

很好我们这个时候可以执行

1
2
export GITHUB_TOEKN=your-token-here
go 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)
}

// set up reverse proxy
rproxy := NewReverseProxy(target, token)

// set up proxy for reverse proxy
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),
}
}

// start a new http server
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
}

// if req is not for image, just return 404
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 builder

WORKDIR /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 alpine
RUN sed -i "s/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g" /etc/apk/repositories \
&& apk add --no-cache tzdata
ENV TZ=Asia/Shanghai

COPY --from=builder /out/server /server

ENV LISTEN_ADDR=:8080
EXPOSE 8080

USER nobody
ENTRYPOINT [ "/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

当你配置好环境变量,你只需要执行

1
docker-compose up

就可以部署一个干净轻量的反代服务(占用内存约2-5MB)

Nginx配置

配置前你需要确认Grpfih所在位置/地址

如果你配置网络, 例如Nginx和Grpfih在同一个网络内,那么可以通过域名grpfih直接连接, 尝试进入nginx容器执行

1
ping grpfih

如果你得到了响应那么证明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; # 定义在http层

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

  1. 为什么不用像 Chevereto 这样的自建图床服务?

    目前能找到最好的自建图床服务就是Chevereto,但是它并不支持Postgresql(也许只是我不知道)我并不打算再花费额外100MB的RAM开启一个MariaDB容器,其次Docker容器内置Nginx, 我个人服务器已经运行Nginx容器,额外的内存占用和个人对php的偏见让我选择拒绝, 最后官方提供的Docker容器没有提供对arm架构的设备的支持

  2. 绝大多数功能只是用Nginx配置就能完成了吧,为什么非要写出一个这样的程序?

    没错,除了Reverse Proxy Over Proxy部分都可以简单的使用Nginx配置,然而我做这个程序的目的就是这一个功能而已, 其它的都是顺便做的, 另外这个程序需要自己写的部分不过一二十行代码,就当是写来玩玩也不为过,顺便体验一下Golang轻量高性能的特性带来的奇妙快感