在 Nginx 的 ngx_http_proxy_module 的使用说明中,有对 X-Accel-Redirect 的描述:

“X-Accel-Redirect” performs an internal redirect to the specified URI;

本文就简单的描述一下在某种特殊场景下 X-Accel-Redirect 的使用。

需求

在实际业务中,需求是在一堆限制条件中,达到某个目的。我这里将复杂的业务特性都去掉,最简化的描述一下。

  1. 有个前端 OpenResty 服务,称为 A,一个后段 OpenResty 服务,称为 B。请求从 A 代理到 B。
  2. 一般情况下,请求在服务 A 中的访问日志,记录在正常日志文件 logs/access.log 里。
  3. 如果后端服务 B 响应为 403,且包含了一个值为 1 的特定的响应头 Invalid-Domain,则该请求在服务 A 中的访问日志,记录在特定日志文件 logs/invalid_domain.log 里。

这里面有一个需要注意的地方:

后端服务 B,其响应 403 的时候,有两种情况,一种是根据业务逻辑,包含值为 1 的特定的响应头 Invalid-Domain,而其他情况的 403 响应中会添加一些其他的正常的响应头,在通过 A 访问时,这些正常的头部不能有缺失。

方案一

最简单的实现方式,是在服务 A 中使用 error_page,最简化的完整配置如下:

events {
}

http {
    # 模拟 后端服务 Server B, 这里为了简化, 让其永远返回 403.
    # 在请求带了参数 invalid_domain 为 1 时, 添加额外的响应头 Invalid-Domain.
    # 其他情况下, 添加响应头 Valid-Domain.
    server {
        listen 8081;
        location / {
            access_log logs/server_b.log;
            header_filter_by_lua_block {
                if ngx.var.arg_invalid_domain == "1" then
                    ngx.header["Invalid-Domain"] = "1"
                else
                    ngx.header["Valid-Domain"] = "1"
                end
                ngx.exit(403)
            }
        }
    }

    # 模拟前端服务 Server A.
    server {
        listen 8082;
        access_log logs/access.log;
        location / {
            proxy_pass http://127.0.0.1:8081;
            proxy_intercept_errors on;
            error_page 403 = @check_invalid_domain;
        }
        location @check_invalid_domain {
            internal;
            if ($upstream_http_invalid_domain = "1") {
                access_log logs/invalid_domain.log;
            }
            return 403;
        }
    }
}

分别使用参数 invalid_domain=1 和不带参数去访问服务 A,会发现访问日志的生效是满足需求的。但有一个容易忽略的地方:当不带参数访问时,后端服务 B 的响应头 Valid-Domain 由于 error_page 带来的内部跳转被去掉了。

这看起来不是什么大事,但我这份配置只是为了演示而极端简化过的,在实际业务中,Valid-Domain 头代表着复杂各异的定制化需求,被去掉是肯定不行的。

问题的焦点,在于我们要同时判断后端响应头和响应状态码,但 error_page 指令中无法对响应头进行组合逻辑判断,所以需要到 error_page 目标 location 中做进一步判断,于是就导致了我们上面说的正常 403 时响应头 Valid-Domain 头的丢失。

方案二

我们再来梳理一下逻辑,看看解决问题的思路。

首先,响应 403 且带有 Invalid-Domain=1 响应头的请求,因为要定制化 log 路径,是需要一个内部跳转到一个新的 location 中去的,即 location @check_invalid_domain 是必须要引入的。可能有人会说,直接用 if 条件组合来定制 access_log 是不是也可以。第一,我个人非常不喜欢 Nginx 的 if 指令,甚至整个 rewrite 模块我都不喜欢使用;第二,由于要判断后端响应码和响应头,这个时候 if 是不管用的,因为它的阶段太靠前。

引入 location @check_invalid_domain 后,所有的 403 响应都会跳转到该 location,而如前所述,业务中的 403 分为两类,有一类的后端响应头是不能丢失的。所以,如果一定要使用这种方案,我们要在 location @check_invalid_domain 中,将后端的响应头再添加进去,在实际业务中,这块会涉及到整个业务框架的部分,可能会很复杂。

那我们再换个思路,能不能在进入 location @check_invalid_domain 之前,就做好响应头的判断,在 location @check_invalid_domain 中,只配置访问日志。但同样如前所属,error_page 无法做组合逻辑,所以似乎路又堵死了。

这时,我们再来看一下 X-Accel-Redirect 头,就会惊喜的发现,它恰好是干这个的。本质上只有 Server B 才真正知道 403 的各种情况,那么只要在需要 Server A 修改访问日志时,加一个 X-Accel-Redirect 头来通知 Server A 做内部跳转就好了,起到一种精确制导的效果,而非将这些逻辑判断交给前端 Server A。

配置如下:

events {
}

http {
    # 模拟 后端服务 Server B, 这里为了简化, 让其永远返回 403.
    # 在请求带了参数 invalid_domain 为 1 时, 添加额外的响应头 Invalid-Domain.
    # 其他情况下, 添加响应头 Valid-Domain.
    server {
        listen 8081;
        location / {
            access_log logs/server_b.log;
            header_filter_by_lua_block {
                if ngx.var.arg_invalid_domain == "1" then
                    ngx.header["X-Accel-Redirect"] = "@check_invalid_domain"
                    ngx.header["Invalid-Domain"] = "1"
                else
                    ngx.header["Valid-Domain"] = "1"
                end
                ngx.exit(403)
            }
        }
    }

    # 模拟前端服务 Server A.
    server {
        listen 8082;
        access_log logs/access.log;
        location / {
            proxy_pass http://127.0.0.1:8081;
        }
        location @check_invalid_domain {
            internal;
            access_log logs/invalid_domain.log;
            return 403;
        }
    }
}

可以看到,与前一版的区别,只有 3 个地方:

  1. 后端 Server B 在适当的时候增加 X-Accel-Redirect 响应头。
  2. 前端 Server A 的配置,去掉 error_page,由响应头 X-Accel-Redirect 在 nginx 的内部处理逻辑来自行做内部跳转。
  3. 内部跳转的目标地址只需要修改访问日志路径。

方案三

真正最灵活的方案,是修改一下 Nginx 的代码,将 access_log 的配置由静态配置改成每请求动态配置,并提供 lua 接口来给业务层调用,这样压根就不需要做内部跳转,代码改起来也不复杂,但实际操作中,能用已有的功能较好的满足需求就已经足够了。