0%

nginx+lua(OpenResty)实现代理日志文件指定行读取

在开发的任务调度平台中,任务执行日志分布在各个机器上,希望用户可以无感知的查看任务的执行日志内容。但日志的数量、大小较大,统一存储将带来较大的存储压力;使用任务执行器后端程序读取指定日志文件并在API响应中返回,这种方式可定制程度最高,在许多的大数据、任务调度开源项目中使用了这种方式实现。

本文将介绍另一种简单易行的方式,使用nginx代理静态日志文件,通过lua脚本读取日志文件的指定行并返回;这种方式需要每台任务执行器所在的机器都提供一个nginx代理,希望只对外暴露一个入口的话那么可以在这些nginx节点之上再增加一层nginx代理,按照节点IP路由。或者,可以通过NFS等方式将日志文件写到网络文件地址中,这样只需要一个nginx读取NFS的共享文件,大量写入的情况下NFS的稳定性、读写性能是否会存在问题?其实按照之前的实际经历,大数据平台任务的shuffle数据写入NFS/Ceph,难免会出现性能下降或者IO的“毛刺”等诸多问题,不过对一般的任务调度系统相信并不会达到这种写入量级,其实大可以放心使用。

本文主要将介绍使用nginx+lua实现指定读取日志行的实现,其他问题我们略过,诸位若想尝试可自行验证。

另外,nginx可以在编译时加入lua模块来支持lua脚本,但其实可以直接使用OpenResty(ngx_openresty,github地址在这里),内置了nginx,支持进行嵌入lua编程脚本,并扩展了大量的第三方模块,使得nginx不仅可用于静态代理,也完全可以通过各类module作为通用型的web应用服务器使用。本文将直接使用OpenResty来实现。

OpenResty的安装

本文在centos7系统上进行安装。

1
2
3
curl https://openresty.org/package/centos/openresty.repo > /etc/yum.repos.d/openresty.repo
yum install -y openresty
yum install -y openresty-resty

默认openresty将安装到/usr/local/openresty下,在/usr/local/openresty下可以看到nginx目录,即是openresty内置的nginx,我们将nginx可执行文件添加到PATH中:

1
2
3
4
5
echo 'export PATH=$PATH:/usr/local/openresty/nginx/sbin' >> /etc/profile
source /etc/profile
nginx -V
# 启动nginx
nginx

安装完成后nginx的配置文件路径为:/usr/local/openresty/nginx/conf

nginx+lua代理日志文件读取指定行

约定日志文件都在/opt/worker/logs这一目录下,为了方便演示,我们直接将nginx的日志软链到这一路径下

1
ln -s /usr/local/openresty/nginx/logs /opt/worker/logs

nginx对外暴露的location为/logs,使用端口80,以下location即为日志文件进行了静态代理

1
2
3
4
5
location /logs {
alias /opt/worker/logs;
default_type 'text/plain; charset=utf-8';
add_header Access-Control-Allow-Origin *;
}

浏览器打开 http://localhost/logs/access.log 即可以查看access.log文件内容

接下来我们逐步完善lua脚本,依次实现在lua脚本中读取url携带的路径的文件并返回内容;按入参http请求携带的tail参数值,读取文件指定的tail行,tail为负数时,读取所有行。

创建与引用lua脚本

创建lua脚本:

1
2
3
4
5
cd /usr/local/openresty/nginx/conf
mkdir scripts
> scripts/tail_file.lua
# hello world,ngx.say的内容将输出到http response中
echo 'ngx.say('hello world')' >> scripts/tail_file.lua

在nginx location中引用这一脚本:

1
2
3
4
5
location /logs {
default_type 'text/plain; charset=utf-8';
add_header Access-Control-Allow-Origin *;
content_by_lua_file /usr/local/openresty/nginx/conf/scripts/tail_file.lua;
}

nginx -s reload重新加载配置后,我们访问 http://localhost/logs/access.log 页面将显示’hello world’

lua脚本输出文件所有内容

接下来我们修改lua脚本,读取url携带的路径的文件的全部内容并返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 将url地址替换为服务器真实文件路径
local uri = ngx.var.uri
-- 第四个参数指定1,限制替换1次
local fpath = string.gsub(uri, "/logs", "/opt/worker/logs", 1)

-- 读取文件全部内容
local f = io.open(fpath, "r")
-- 文件不存在时,返回404
if f == nil
then
ngx.status = 404
ngx.say("file not found")
ngx.exit(ngx.OK)
end
local content = f:read("*all")
f:close()
ngx.say(content)

lua脚本输出文件指定行内容

我们在lua中读取http请求携带的tail参数值,读取文件指定的尾部n行,tail为负数时,读取所有行。

读取文件尾部的n行,我们可以使用lua脚本lines()逐行读取文件,通过滑动窗口丢弃掉不需要的行。

这里我们选取了通过lua调用shell命令获取文件尾部n行的方式:

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
-- 将url地址替换为服务器真实文件路径
local uri = ngx.var.uri
-- 第四个参数指定1,限制替换1次
local fpath = string.gsub(uri, "/logs", "/opt/worker/logs", 1)
-- 文件不存在时,返回404
local f = io.open(fpath, "r")
if f == nil
then
ngx.status = 404
ngx.say("file not found")
ngx.exit(ngx.OK)
end

local tail = ngx.var.arg_tail
-- tail约定合法值范围为:非零整数
if tonumber(tail) == 0
then
ngx.status = 400
ngx.say("arg tail should not be 0")
ngx.exit(ngx.OK)
end
-- tail默认为10
if tail == nil
then
tail = 10
end

-- 调用shell获取文件尾部n行
-- 拼接cmd
local cmd = 'tail -n ' .. tail .. ' ' .. fpath
-- 执行cmd并读取执行结果
local handle = io.popen(cmd)
local result = handle:read("*a")
handle:close()
-- 输出结果
ngx.say(content)

为了脚本逻辑清晰,我们使用function抽取部分逻辑:

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
-- 调用shell读取文件尾部tail行
local function tail_file(fpath, tail)
-- 调用shell获取文件尾部n行
-- 拼接cmd
local cmd = 'tail -n ' .. tail .. ' ' .. fpath
-- 执行cmd并读取执行结果
local handle = io.popen(cmd)
local content = handle:read("*a")
handle:close()
return content
end

-- 读取文件全部内容
local function read_file(fpath)
-- 读取文件全部内容
local f = io.open(fpath, "r")
local content = f:read("*all")
return content
end

-- 将url地址替换为服务器真实文件路径
local uri = ngx.var.uri
-- 第四个参数指定1,限制替换1次
local fpath = string.gsub(uri, "/logs", "/opt/worker/logs", 1)
-- 文件不存在时,返回404
local f = io.open(fpath, "r")
if f == nil
then
ngx.status = 404
ngx.say("file not found")
ngx.exit(ngx.OK)
end

local tail = ngx.var.arg_tail
-- tail约定合法值范围为:非零整数
if tonumber(tail) == 0
then
ngx.status = 400
ngx.say("arg tail should not be 0")
ngx.exit(ngx.OK)
end
-- tail默认为10
if tail == nil
then
tail = 10
end

-- tail为正数时,读取文件尾部n行;tail为负数时,读取文件全部内容
local result = ''
if tonumber(tail) > 0
then
result = tail_file(fpath, tail)
else
result = read_file(fpath)
end
-- 输出结果
ngx.say(result)

此时,打开 http://localhost/logs/access.log 将返回文件尾部10行内容;打开 http://localhost/logs/access.log?tail=5 将返回文件尾部5行内容;打开 http://localhost/logs/access.log?tail=-1 将返回文件全部内容

安全性问题的思考

在上文中,我们将用户输入的url地址添加前缀后作为服务器实际文件路径读取文件内容,在使用shell读取时,直接将路径拼接到命令中:

1
local cmd = 'tail -n ' .. tail .. '  ' .. fpath

这里我们不难想到,如果用户在url中输入 .. ~ 等代表路径的字符串,或者通过引号/反引号等(’,”,`,$)注入shell指令,那么就可能成功读取到服务器任意文件内容,或者执行任意指令

这里我并没有进行验证,但是不得不考虑这类情况,简单处理的话,可以在lua脚本中加入一段字符合法性检查进行过滤:

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
-- 调用shell读取文件尾部tail行
local function tail_file(fpath, tail)
-- 调用shell获取文件尾部n行
-- 拼接cmd
local cmd = 'tail -n ' .. tail .. ' ' .. fpath
-- 执行cmd并读取执行结果
local handle = io.popen(cmd)
local content = handle:read("*a")
handle:close()
return content
end

-- 读取文件全部内容
local function read_file(fpath)
-- 读取文件全部内容
local f = io.open(fpath, "r")
local content = f:read("*all")
return content
end

-- url输入路径是否合法
local function is_legal_url(uri)
-- find函数指定第四个参数为true,表示搜索的字符串为纯文本,默认false表示pattern匹配
if string.find(uri, "..", 1, true)
then
return false
end
if string.find(uri, "~", 1, true)
then
return false
end
if string.find(uri, "`", 1, true)
then
return false
end
if string.find(uri, "'", 1, true)
then
return false
end
if string.find(uri, "\"", 1, true)
then
return false
end
if string.find(uri, "$", 1, true)
then
return false
end
return true
end

-- 将url地址替换为服务器真实文件路径
local uri = ngx.var.uri
if not is_legal_url(uri)
then
ngx.status = 400
ngx.say("uri not legal")
ngx.exit(ngx.OK)
end

-- 第四个参数指定1,限制替换1次
local fpath = string.gsub(uri, "/logs", "/opt/worker/logs", 1)
-- 文件不存在时,返回404
local f = io.open(fpath, "r")
if f == nil
then
ngx.status = 404
ngx.say("file not found")
ngx.exit(ngx.OK)
end

local tail = ngx.var.arg_tail
-- tail约定合法值范围为:非零整数
if tonumber(tail) == 0
then
ngx.status = 400
ngx.say("arg tail should not be 0")
ngx.exit(ngx.OK)
end
-- tail默认为10
if tail == nil
then
tail = 10
end

-- tail为正数时,读取文件尾部n行;tail为负数时,读取文件全部内容
local result = ''
if tonumber(tail) > 0
then
result = tail_file(fpath, tail)
else
result = read_file(fpath)
end
-- 输出结果
ngx.say(result)

通过加入一段字符过滤逻辑,限制url不能存在 .. ~ ‘ “ ` $ 等字符。在此之外,继续加入了以下限制,逻辑并不复杂大家自行实现:限制nginx的启动用户和lua脚本中调用shell命令使用的用户(su - asafeuser -c “cmd here”),将lua中具备读取权限的文件限制在部分路径下。

但是安全加固是一门高深、有相当壁垒的学问,加上了以上措施我相信也并不会完全安全,最终只是在测试环境中限制了内网访问。