0%

Nginx 反向代理返回结果为空的问题

最近在开发过程中遇到了这么一个问题: 现在有一个 Web 项目,前端是使用 Vue.js 开发的,整个前端需要部署到 K8S 上,后端和前端分开,同样也需要部署到 K8S 上,因此二者需要打包为 Docker 镜像。 对前端来说,打包 Docker 就遇到了一个问题:跨域访问问题。 因此一个普遍的解决方案就是使用 Nginx 做反向代理。 一般来说,我们需要在打包时配置一下 nginx.conf 文件,然后在 Dockerfile 里面指定即可。

Dockerfile

首先看下 Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# build stage
FROM node:lts-alpine as build-stage
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# production stage
FROM nginx:lts-alpine as production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/
RUN rm /etc/nginx/conf.d/default.conf
&& mv /etc/nginx/conf.d/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

一般来说,对于常规的 Vue.js 前端项目,Dockerfile 就这么写就行了。 简单介绍一下:

  • 第一步,使用 Node.js 镜像,在 Node.js 环境下对项目进行编译,默认会输出到 dist 文件夹下。
  • 第二步,使用新的 Nginx 镜像,将编译得到的前端文件拷贝到 nginx 默认 serve 的目录,然后把自定义的 nginx.conf 文件替换为 Nginx 默认的 conf 文件,运行即可。

反向代理

这里比较关键的就是 nginx.conf 文件了,为了解决跨域问题,我们一般会将后端的接口进行反向代理。 一般来说,后端的 API 接口都是以 api 为开头的,所以我们需要代理 api 开头的接口地址,nginx.conf 内容一般可以这么写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
server {
listen 80;
server_name localhost;

location /api/ {
proxy_pass http://domain.com/api/;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
}

location / {
root /usr/share/nginx/html;
index index.html index.htm;
}

location = /50x.html {
root /usr/share/nginx/html;
}

error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
}

一般来说,以上的写法是没有问题的,proxy_set_header 也把一些 Header 进行设置,转发到后端服务器。 如果你这么写,打包 Docker 之后,测试没有遇到问题,那就完事了。

问题

但我遇到了一个奇怪的问题,某个接口在请求的时候,状态码还是 200,但其返回值总是为空,即 Response Data 的内容完全为空。 但是服务器端看 Log 确实有正常返回 Response,使用 Vue 的 devServer 也是正常的,使用 Postman 来请求也是正常的,但是经过 Nginx 这么一反向代理就不行了,什么 Response 都接收不到。 部署到 Prod 环境之后,浏览器上面可以得到这么个错误:

1
ERR_INCOMPLETE_CHUNKED_ENCODING

image-20191207042932549 最后经排查,发现后端接口使用时设定了 Transfer-Encoding: chunked 响应头:

1
Transfer-Encoding: chunked

这是啥?这时候就需要引出 Keep-Alive 的相关问题了。

什么是 Keep-Alive?

我们知道 HTTP 协议采用「请求-应答」模式,当使用普通模式,即非 Keep-Alive 模式时,每个请求/应答客户和服务器都要新建一个连接,完成之后立即断开连接(HTTP 协议为无连接的协议)。当使用 Keep-Alive 模式(又称持久连接、连接重用)时,Keep-Alive 功能使客户端到服务器端的连接持续有效,当出现对服务器的后继请求时,Keep-Alive 功能避免了建立或者重新建立连接。

  • HTTP 1.0 中默认是关闭 Keep-Alive 的,需要在 HTTP 头加入Connection: Keep-Alive,才能启用 Keep-Alive
  • HTTP 1.1 中默认启用 Keep-Alive,如果请求头中加入 Connection: close,Keep-Alive 才关闭。

目前大部分浏览器都是用 HTTP 1.1 协议,也就是说默认都会发起 Keep-Alive 的连接请求了,所以是否能完成一个完整的 Keep-Alive 连接就看服务器设置情况。 启用 Keep-Alive 模式肯定更高效,性能更高。因为避免了建立/释放连接的开销。

Keep-Alive 模式下如何传输数据

Keep-Alive 模式,客户端如何判断请求所得到的响应数据已经接收完成呢?或者说如何知道服务器已经发生完了数据? 我们已经知道了,Keep-Alive 模式发送完数据,HTTP 服务器不会自动断开连接,所有不能再使用返回 EOF(-1)来判断。 那么怎么判断呢?一个是使用 Content-Length ,一个是使用 Transfer-Encoding。

Content-Length

顾名思义,Conent-Length 表示实体内容长度,客户端(服务器)可以根据这个值来判断数据是否接收完成。 由于 Content-Length 字段必须真实反映实体长度,但实际应用中,有些时候实体长度并没那么好获得,例如实体来自于网络文件,或者由动态语言生成。这时候要想准确获取长度,只能开一个足够大的 buffer,等内容全部生成好再计算。但这样做一方面需要更大的内存开销,另一方面也会让客户端等更久。 我们在做 WEB 性能优化时,有一个重要的指标叫 TTFB(Time To First Byte),它代表的是从客户端发出请求到收到响应的第一个字节所花费的时间。大部分浏览器自带的 Network 面板都可以看到这个指标,越短的 TTFB 意味着用户可以越早看到页面内容,体验越好。可想而知,服务端为了计算响应实体长度而缓存所有内容,跟更短的 TTFB 理念背道而驰。但在 HTTP 报文中,实体一定要在头部之后,顺序不能颠倒,为此我们需要一个新的机制:不依赖头部的长度信息,也能知道实体的边界。 但是如果消息中没有 Conent-Length,那该如何来判断呢?又在什么情况下会没有 Conent-Length 呢?

Transfer-Encoding

当客户端向服务器请求一个静态页面或者一张图片时,服务器可以很清楚地知道内容大小,然后通过 Content-length 消息首部字段告诉客户端需要接收多少数据。但是如果是动态页面等时,服务器是不可能预先知道内容大小,这时就可以使用 分块编码模式来传输数据了。即如果要一边产生数据,一边发给客户端,服务器就需要在请求头中使用Transfer-Encoding: chunked 这样的方式来代替 Content-Length,这就是分块编码。 分块编码相当简单,在头部加入 Transfer-Encoding: chunked 之后,就代表这个报文采用了分块编码。这时,报文中的实体需要改为用一系列分块来传输。每个分块包含十六进制的长度值和数据,长度值独占一行,长度不包括它结尾的 CRLF(rn),也不包括分块数据结尾的 CRLF。最后一个分块长度值必须为 0,对应的分块数据没有内容,表示实体结束。

回归问题

那么我说了这么一大通有什么用呢? OK,在我遇到的业务场景中,我发现服务器的响应头中就包含了Transfer-Encoding: chunked 这个字段。 而这个字段,在 HTTP 1.0 是不被支持的。 而 Nginx 的反向代理,默认用的就是 HTTP 1.0,那就导致了数据无法获取的问题,可以参考 Nginx 的官方文档说明:http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass。 原文中:

1
2
3
Syntax: proxy_http_version 1.0 | 1.1;
Default: proxy_http_version 1.0;
By default, version 1.0 is used. Version 1.1 is recommended for use with keepalive connections and NTLM authentication.

所以,我们如果要解决这个问题,只需要设置一下 HTTP 版本为 1.1 就好了: 修改 nginx.conf 文件如下:

1
2
3
4
5
6
7
location /api/ {
proxy_pass http://domain.com/api/;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
}

这里就增加了一行:

1
proxy_http_version 1.1;

这样再测试,反向代理就会支持 Transfer-Encoding: chunked 模式了,这也就呼应了之前在浏览器中遇到的 ERR_INCOMPLETE_CHUNKED_ENCODING 错误。 自此,问题完美解决。

复盘记录

一开始本来只想简单一记录就了事的,但一边写,发现某个地方还可以展开写得更详细。 所以干脆最后我对这个问题进行了详细的复盘和记录。在写本文之前,我其实只思考到了 Keep-Alive 和 HTTP 1.1 的问题,其实我对 Transfer-Encoding 这个并没有去深入思考。在边写边总结的过程中,为了把整个脉络讲明白,我又查询了一些 Transfer-Encoding 和 Nginx 的官方文档,对这块的了解变得更加深入,相当于我在整个记录的过程中,又对整个流程梳理了一遍,同时又有额外的收获。 所以,遇到问题,深入去思考、总结和复盘,是很有帮助的,这会让我们对问题的看法和理解更加透彻。 怎么说呢?在开发过程中,难免会遇到一些奇奇怪怪的 Bug,但这其实只是技术问题,总会解决的。 但怎样在开发过程中,不断提高自己的技术能力,我觉得需要从每一个细节出发,去思考一些事情的来龙去脉。思考得越多,我们对整个事件的把握也会越清晰,以后如果再遇到类似的或者关联的事情,就会迎刃而解了。 平时我们可能很多情况下都在写业务代码,可能比较枯燥,感觉对技术没有实质性的提升,但如果我们能从中提炼出一些核心的问题或解决方案,这才是能真正提高技术的时候,这才是最有价值的。

参考文章

本文部分内容改写或摘自下列内容。

  • HTTP Keep-Alive模式:https://www.cnblogs.com/skynet/archive/2010/12/11/1903347.html
  • Nginx proxy_set_header 理解:https://www.jianshu.com/p/cc5167032525
  • 使用 Docker 打造超溜的前端环境:https://github.com/axetroy/blog/issues/178
  • HTTP 协议中的 Transfer-Encoding:https://imququ.com/post/transfer-encoding-header-in-http.html