本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh)
本文作者: 苏洋
创建时间: 2020年11月01日
统计字数: 13071字
阅读时间: 27分钟阅读
本文链接: https://soulteary.com/2020/11/01/use-nginx-to-build-a-front-end-log-statistics-service-service.html
-----
# 使用 Nginx 构建前端日志统计服务(打点采集)服务
工作中经常会遇到需要“数据支撑”决策的时候,那么可曾想过这些数据从何而来呢?如果业务涉及 Web 服务,那么这些数据的来源之一便是服务器上各种服务器的请求数据,如果我们将专门用于统计的数据进行服务器区分,有一些服务器专注于接收“统计类型”的请求,那么产生的这些日志便是“打点日志”。
本文将介绍如何在容器中使用 Nginx 简单搭建一个支持前端使用的统计(打点采集)服务,避免引入过多的技术栈,徒增维护成本。
## 写在前面
不知你是否想过一个问题,当一个页面中的打点事件比较多的时候,页面打开的瞬间将同时发起无数请求,此刻非宽带环境下用户体验将不复存在,打点服务器也将面临来自友军的业务 DDoS 行为。
所以这几年中,不断有公司将数据统计方案由 GET 切换为 POST 方案,结合自研定制的 SDK,对客户端的数据统计进行进行“打包合并”,并进行有一定频率的增量日志上报,极大的解决了前端性能问题、以及降低了服务器的压力。
五年前,我曾分享过如何[构建易于扩展的前端统计脚本](https://soulteary.com/2015/09/22/build-scalable-analytics-script.html),感兴趣可以进行关联阅读。
## POST 请求在 Nginx 环境下的问题
看到这个小节的标题,你或许会感到迷惑,日常对 Nginx 进行 POST 交互司空见惯,会有什么问题呢?
我们不妨做一个小实验,使用容器启动一个 Nginx 服务:
```bash
docker run --rm -it -p 3000:80 nginx:1.19.3-alpine
```
然后使用 **curl** 模拟日常业务中的 POST 请求:
```bash
curl -d '{"key1":"value1", "key2":"value2"}' -X POST http://localhost:3000
```
你将看到下面的返回结果:
```html
405 Not Allowed
405 Not Allowed
nginx/1.19.3
```
按图索骥,查看 Nginx 模块 [`modules/ngx_http_stub_status_module.c`](https://github.com/nginx/nginx/blob/4bf4650f2f10f7bbacfe7a33da744f18951d416d/src/http/modules/ngx_http_stub_status_module.c) 和 [`http/ngx_http_special_response.c`](https://github.com/nginx/nginx/blob/15544440425008d5ad39a295b826665ad56fdc90/src/http/ngx_http_special_response.c)的源码可以看到下面的实现:
```c
static ngx_int_t
ngx_http_stub_status_handler(ngx_http_request_t *r)
{
size_t size;
ngx_int_t rc;
ngx_buf_t *b;
ngx_chain_t out;
ngx_atomic_int_t ap, hn, ac, rq, rd, wr, wa;
if (!(r->method & (NGX_HTTP_GET|NGX_HTTP_HEAD))) {
return NGX_HTTP_NOT_ALLOWED;
}
...
}
...
static char ngx_http_error_405_page[] =
"" CRLF
"405 Not Allowed" CRLF
"" CRLF
"405 Not Allowed
" CRLF
;
#define NGX_HTTP_OFF_4XX (NGX_HTTP_LAST_3XX - 301 + NGX_HTTP_OFF_3XX)
...
ngx_string(ngx_http_error_405_page),
ngx_string(ngx_http_error_406_page),
...
```
没错,默认情况下,NGINX 并不支持记录 POST 请求,会根据 [RFC7231](https://tools.ietf.org/html/rfc7231#section-6.5.5) 展示错误码405。所以一般情况下,我们会借助 Lua /Java / PHP / Go / Node 等动态语言进行辅助解析。
那么如何来解决这个问题呢?能否单纯的使用性能好、又轻量的 Nginx 来完成对 POST 请求的支持,而不借助外力吗?
## 让 Nginx “原生”支持 POST 请求
为了更清晰的展示配置,我们接下来使用 compose 来启动 Nginx 进行实验,在编写脚本之前,我们需要先获取配置文件,使用下面的命令行将指定版本的 Nginx 的配置文件保存到当前目录中。
```bash
docker run --rm -it nginx:1.19.3-alpine cat /etc/nginx/conf.d/default.conf > default.conf
```
默认的配置文件内容如下:
```bash
server {
listen 80;
server_name localhost;
#charset koi8-r;
#access_log /var/log/nginx/host.access.log main;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
#error_page 404 /404.html;
# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
```
稍作精简,我们会得到一个更简单的配置文件,并在其中添加一行 `error_page 405 =200 $uri;`:
```bash
server {
listen 80;
server_name localhost;
charset utf-8;
location / {
return 200 "soulteary";
}
error_page 405 =200 $uri;
}
```
将本小节开始部分的命令改写为 `docker-compose.yml` 并添加 `volumes`,把刚刚导出的配置文件映射到容器内,方便使用后续使用 compose 启动容器进行验证。
```yaml
version: "3"
services:
ngx:
image: nginx:1.19.3-alpine
restart: always
ports:
- 3000:80
volumes:
- ./default.conf/:/etc/nginx/conf.d/default.conf
```
使用 `docker-compose up` 启动服务,然后使用前面的 `curl` 模拟 POST 验证请求是否正常。
```bash
curl -d '{"key1":"value1", "key2":"value2"}' -H "Content-Type: application/json" -H "origin:gray.baai.ac.cn" -X POST http://localhost:3000
soulteary
```
执行完毕,除了得到 “soulteary” 这个字符串返回之外, Nginx 日志记录也会多一条看起来正常的记录:
```bash
ngx_1 | 192.168.16.1 - - [31/Oct/2020:14:24:48 +0000] "POST / HTTP/1.1" 200 0 "-" "curl/7.64.1" "-"
```
但是,如果你细心的话,你会发现日志中并未包含我们发送的数据,那么这个问题该如何解决呢?
## 解决 Nginx 日志中丢失的 POST 数据
这个问题其实是老生常谈,默认 Nginx 服务器记录日志格式并不包含 POST Body(性能考虑),并且在没有 `proxy_pass` 的情况下,是不会解析 POST Body的。
先执行下面的命令:
```bash
docker run --rm -it nginx:1.19.3-alpine cat /etc/nginx/nginx.conf
```
可以看到默认的 `log_format` 配置规则中确实并没有任何关于 POST Body 中的数据。
```bash
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}
```
所以解决这个问题的方案也不难,新增一个日志格式,添加 POST Body 变量(`request_body`),然后添加一个 `proxy_pass` 路径,激活 Nginx 解析 POST Body 的处理逻辑。
考虑到维护问题,我们前文中的配置文件与这个配置进行合并,并定义一个名为 `/internal-api-path` 的路径:
```bash
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" $request_body';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
charset utf-8;
location / {
proxy_pass http://127.0.0.1/internal-api-path;
}
location /internal-api-path {
# access_log off;
default_type application/json;
return 200 '{"code": 0, data:"soulteary"}';
}
error_page 405 =200 $uri;
}
}
```
将新配置文件保存为 nginx.conf 后,调整 compose 中的 `volumes` 配置信息,再次使用 `docker-compose up` 启动服务。
```yaml
volumes:
- ./nginx.conf/:/etc/nginx/nginx.conf
```
再次使用 `curl` 模拟之前的 POST 请求,会看到 Nginx 日志多了两条记录,第一条记录中包含了我们所需要的 POST 数据:
```TeXT
192.168.192.1 - - [31/Oct/2020:15:05:48 +0000] "POST / HTTP/1.1" 200 29 "-" "curl/7.64.1" "-" {\x22key1\x22:\x22value1\x22, \x22key2\x22:\x22value2\x22}
127.0.0.1 - - [31/Oct/2020:15:05:48 +0000] "POST /internal-api-path HTTP/1.0" 200 29 "-" "curl/7.64.1" "-" -
```
但是这里不完美的地方还有很多:
- 服务器可以正常接收 GET 请求,我们在日志处理的时候需要进行大量“抛弃动作”,并且在暂存的时候,磁盘空间也存在不必要的浪费。
- 用于激活 Nginx POST Body 解析能力的路径可以被随意调用,产生无意义日志,同样存在上面的问题。
- 更关键的,日志中的数据看起来还需要额外加工处理,进行转码,解析效率会有不必要的性能损耗。
接下来我们来继续解决这些问题。
## 改进 Nginx 配置,优化日志记录
首先,在日志格式中添加 `escape=json` 参数,要求 Nginx 解析日志请求中的 JSON 数据:
```TeXT
log_format main escape=json '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" $request_body';
```
然后,在不需要记录日志的路径中,添加 `access_log off;` 指令,避免不必要的日志进行记录。
```c
location /internal-api-path {
access_log off;
default_type application/json;
return 200 '{"code": 0, data:"soulteary"}';
}
```
接着使用 Nginx `map` 指令,和 Nginx 中的条件判断,过滤非 POST 请求的日志记录,以及拒绝处理非 POST 请求。
```c
map $request_method $loggable {
default 0;
POST 1;
}
...
server {
location / {
if ( $request_method !~ ^POST$ ) { return 405; }
access_log /var/log/nginx/access.log main if=$loggable;
proxy_pass http://127.0.0.1/internal-api-path;
}
...
}
```
再次使用 `curl` 请求,会看到日志已经能够正常解析,不会出现两条日志了。
```c
192.168.224.1 - [31/Oct/2020:15:19:59 +0000] "POST / HTTP/1.1" 200 29 "" "curl/7.64.1" "" {\"key1\":\"value1\", \"key2\":\"value2\"}
```
同时,也不会再记录任何非 POST 请求,使用 POST 请求的时候,会提示 405 错误状态。
这个时候,你或许会好奇,为什么这个 405 和前文中不同,不会被重定向为 200 呢?这是因为这个 405 是我们根据触发条件“手动设置”的,而非 Nginx 逻辑运行过程中判断出新的结果。
当前的 Nginx 配置如下:
```c
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main escape=json '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" $request_body';
sendfile on;
keepalive_timeout 65;
map $request_method $loggable {
default 0;
POST 1;
}
server {
listen 80;
server_name localhost;
charset utf-8;
location / {
if ( $request_method !~ ^POST$ ) { return 405; }
access_log /var/log/nginx/access.log main if=$loggable;
proxy_pass http://127.0.0.1/internal-api-path;
}
location /internal-api-path {
access_log off;
default_type application/json;
return 200 '{"code": 0, "data":"soulteary"}';
}
error_page 405 =200 $uri;
}
}
```
但是到这里就真的结束了吗?
## 模拟前端客户端常见跨域请求
我们打开熟悉的“百度”,在控制台中输入下面的代码,模拟一次常见的业务跨域请求。
```js
async function testCorsPost(url = '', data = {}) {
const response = await fetch(url, {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
redirect: 'follow',
referrerPolicy: 'no-referrer',
body: JSON.stringify(data)
});
return response.json();
}
testCorsPost('http://localhost:3000', { hello: "soulteary" }).then(data => console.log(data));
```
代码执行完毕后,你会看到一个经典的提示信息:
```TeXT
Access to fetch at 'http://localhost:3000/' from origin 'https://www.baidu.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
POST http://localhost:3000/ net::ERR_FAILED
```
观察 Network 网络面板,会看到有两条失败的新请求:
- Request URL: http://localhost:3000/
- Request Method: OPTIONS
- Status Code: 405 Not Allowed
- Request URL: http://localhost:3000/
- Request Method: POST
- 没有响应结果
让我们继续调整配置,解决这个常见的问题吧。
## 使用 Nginx 解决前端跨域问题
我们首先调整之前的过滤规则,允许 `OPTIONS` 请求的处理。
```c
if ( $request_method !~ ^(POST|OPTIONS)$ ) { return 405; }
```
跨域请求是前端常见场景,许多人会偷懒使用 “`*`”来解决问题,但是 Chrome 等现代浏览器在新版本中有些场景不能使用这样宽松的规则,而且为了业务安全,一般情况,我们会在服务端设置允许进行跨域请求的域名白名单,参考上文中的方式,我们可以很容易的定义出类似下面的 Nginx `map` 配置,来谢绝所有前端非授权跨域请求:
```c
map $http_origin $corsHost {
default 0;
"~(.*).soulteary.com" 1;
"~(.*).baidu.com" 1;
}
server {
...
location / {
...
if ( $corsHost = 0 ) { return 405; }
...
}
}
```
这里有一个 trick 的地方,Nginx 的路由内的规则编写,并不完全类似级编程语言一样,可以顺序执行,是具备“优先级/覆盖”关系的,所以为了能够让前端正常调用接口进行数据提交,这里需要这样书写规则,存在四行代码冗余。
```c
if ( $corsHost = 0 ) { return 405; }
if ( $corsHost = 1 ) {
# 不需要 Cookie
add_header 'Access-Control-Allow-Credentials' 'false';
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
add_header 'Access-Control-Allow-Methods' 'POST,OPTIONS';
add_header 'Access-Control-Allow-Origin' '$http_origin';
}
# OPTION 请求返回 204 ,并去掉 BODY响应,因 NGINX 限制,需要重复上面的前四行配置
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Credentials' 'false';
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
add_header 'Access-Control-Allow-Methods' 'POST,OPTIONS';
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
```
再次在网页中执行前面的 JavaScript 代码,会发现请求已经可以正常执行了,前端数据会返回:
```json
{code: 0, data: "soulteary"}
```
而 Nginx 日志,则会多一条符合预期的记录:
```TeXT
172.20.0.1 - [31/Oct/2020:15:49:17 +0000] "POST / HTTP/1.1" 200 31 "" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36" "" {\"hello\":\"soulteary\"}
```
而使用 `curl` 执行之前的命令,继续模拟纯接口调用,则会发现出现了 405 错误响应,这是因为我们的请求中不包含 `origin` 请求头,无法表明我们的来源身份,在请求中使用 `-H` 参数补全这个数据,即可拿到符合预期的返回:
```bash
curl -d '{"key1":"value1", "key2":"value2"}' -H "Content-Type: application/json" -H "origin:www.baidu.com" -X POST http://localhost:3000/
{"code": 0, "data":"soulteary"}
```
## 相对完整的 Nginx 配置
到现在为止,我们基本实现一般的采集功能,满足基本诉求的 Nginx 配置信息如下:
```c
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main escape=json '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" $request_body';
sendfile on;
keepalive_timeout 65;
map $request_method $loggable {
default 0;
POST 1;
}
map $http_origin $corsHost {
default 0;
"~(.*).soulteary.com" 1;
"~(.*).baidu.com" 1;
}
server {
listen 80;
server_name localhost;
charset utf-8;
location / {
if ( $request_method !~ ^(POST|OPTIONS)$ ) { return 405; }
access_log /var/log/nginx/access.log main if=$loggable;
if ( $corsHost = 0 ) { return 405; }
if ( $corsHost = 1 ) {
# 不需要 Cookie
add_header 'Access-Control-Allow-Credentials' 'false';
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
add_header 'Access-Control-Allow-Methods' 'POST,OPTIONS';
add_header 'Access-Control-Allow-Origin' '$http_origin';
}
# OPTION 请求返回 204 ,并去掉 BODY响应,因 NGINX 限制,需要重复上面的前四行配置
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Credentials' 'false';
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Date,Pragma';
add_header 'Access-Control-Allow-Methods' 'POST,OPTIONS';
add_header 'Access-Control-Allow-Origin' '$http_origin';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://127.0.0.1/internal-api-path;
}
location /internal-api-path {
access_log off;
default_type application/json;
return 200 '{"code": 0, "data":"soulteary"}';
}
error_page 405 =200 $uri;
}
}
```
如果我们结合容器使用,只需要在其中添加一段额外的路由定义,单独用于健康检查,就能够实现一个简单稳定的采集服务。继续对接后续的数据转存、处理程序。
```c
location /health {
access_log off;
return 200;
}
```
而 compose 配置文件,相比较之前,不过多了几行健康检查定义罢了:
```yaml
version: "3"
services:
ngx:
image: nginx:1.19.3-alpine
restart: always
ports:
- 3000:80
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./nginx.conf:/etc/nginx/nginx.conf
healthcheck:
test: wget --spider localhost/health || exit 1
interval: 5s
timeout: 10s
retries: 3
```
结合 Traefik ,可以轻松进行实例的水平扩展,处理更多的请求。感兴趣可以翻阅我[之前的文章](https://soulteary.com/tags/traefik.html)。
## 最后
本文仅介绍了数据采集的皮毛,更多的内容或许后续有时间会细细道来。要给我家毛孩子付猫粮尾款啦,先写到这里吧。
--EOF