一、OpenResty日志切割问题的由来

作为一个基于Nginx和Lua的高性能Web平台,OpenResty在日志处理方面继承了Nginx的特性。但很多开发者在实际使用中经常会遇到日志文件膨胀的问题,特别是在高并发场景下,单个日志文件很快就会变得巨大无比。

这个问题我去年就遇到过。当时我们一个电商项目在促销期间,access.log一天就涨到了20GB,不仅占满磁盘空间,还导致日志分析工具直接崩溃。更糟的是,由于没有配置日志切割,我们不得不手动清理日志,结果不小心把当天的关键日志也给删了。

二、Nginx原生日志切割方案

先说说最基础的解决方案 - 使用Linux自带的logrotate工具。这是大多数Nginx用户的首选方案,配置起来也确实简单:

# /etc/logrotate.d/nginx 配置示例
/var/log/nginx/*.log {
    daily       # 每天轮转
    missingok   # 如果日志丢失也不报错
    rotate 30   # 保留30天的日志
    compress    # 使用gzip压缩旧日志
    delaycompress # 延迟压缩前一个日志文件
    notifempty  # 如果日志为空就不轮转
    create 640 nginx adm  # 设置新日志文件的权限
    sharedscripts
    postrotate
        [ ! -f /var/run/nginx.pid ] || kill -USR1 `cat /var/run/nginx.pid`
    endscript
}

这个方案有几个明显的缺点:

  1. 切割不是实时的,最小粒度是天
  2. 依赖外部工具,增加了系统复杂性
  3. 切割时可能丢失部分日志
  4. 对于OpenResty的特殊日志格式支持不够友好

三、OpenResty的Lua日志切割方案

既然OpenResty支持Lua,我们何不直接用Lua来实现更灵活的日志切割?下面是我在实际项目中使用的方案:

-- lua/log_rotate.lua
local function setup_log_rotation(premature)
    if premature then return end
    
    -- 获取当前时间
    local now = ngx.time()
    local today = os.date("%Y%m%d", now)
    
    -- 定义日志路径
    local log_dir = "/var/log/openresty/"
    local access_log = log_dir .. "access.log"
    local error_log = log_dir .. "error.log"
    
    -- 只在新的一天执行切割
    if not ngx.shared.log_date or ngx.shared.log_date ~= today then
        ngx.shared.log_date = today
        
        -- 重命名当前日志文件
        os.rename(access_log, access_log .. "." .. today)
        os.rename(error_log, error_log .. "." .. today)
        
        -- 重新打开日志文件
        local ok, err = os.execute("kill -USR1 " .. ngx.worker.pid())
        if not ok then
            ngx.log(ngx.ERR, "failed to reopen logs: ", err)
        end
    end
end

-- 每60秒检查一次是否需要切割
local ok, err = ngx.timer.every(60, setup_log_rotation)
if not ok then
    ngx.log(ngx.ERR, "failed to create timer: ", err)
end

然后在nginx.conf中加载这个Lua脚本:

http {
    lua_shared_dict log_date 1m;
    init_worker_by_lua_file lua/log_rotate.lua;
    
    server {
        access_log /var/log/openresty/access.log;
        error_log /var/log/openresty/error.log;
    }
}

这个方案的优势很明显:

  1. 切割粒度可以精确到秒
  2. 完全集成在OpenResty内部,不依赖外部工具
  3. 可以灵活处理各种日志格式
  4. 切割过程不会丢失日志

四、进阶方案:按大小切割日志

对于特别高流量的场景,按天切割可能还不够。我们可以改进上面的方案,实现按日志大小切割:

-- lua/size_based_rotation.lua
local MAX_LOG_SIZE = 100 * 1024 * 1024  -- 100MB

local function check_log_size()
    local access_log = "/var/log/openresty/access.log"
    local f = io.open(access_log, "r")
    if f then
        local size = f:seek("end")
        f:close()
        
        if size > MAX_LOG_SIZE then
            local timestamp = os.date("%Y%m%d%H%M%S")
            os.rename(access_log, access_log .. "." .. timestamp)
            os.execute("kill -USR1 " .. ngx.worker.pid())
        end
    end
end

local function log_rotation(premature)
    if premature then return end
    check_log_size()
end

ngx.timer.every(60, log_rotation)  -- 每分钟检查一次

五、注意事项和最佳实践

在实际使用这些方案时,有几个关键点需要注意:

  1. 文件权限问题:确保OpenResty工作进程有权限读写日志目录
  2. 共享内存大小:lua_shared_dict需要足够大来存储状态
  3. 定时器间隔:不要设置得太频繁,避免影响性能
  4. 日志压缩:可以考虑在切割后自动压缩旧日志
  5. 异常处理:要妥善处理文件操作可能出现的错误

这里还有一个完整的生产环境配置示例:

http {
    lua_shared_dict log_rotation 10m;
    init_worker_by_lua_block {
        local MAX_SIZE = 200 * 1024 * 1024  -- 200MB
        local ROTATE_INTERVAL = 300  -- 5分钟
        
        local function rotate_log(log_path)
            local timestamp = os.date("%Y%m%d%H%M%S")
            local new_path = log_path .. "." .. timestamp
            os.rename(log_path, new_path)
            
            -- 异步压缩旧日志
            local cmd = "gzip " .. new_path .. " &"
            os.execute(cmd)
        end
        
        local function check_logs()
            local logs = {
                "/var/log/openresty/access.log",
                "/var/log/openresty/error.log"
            }
            
            for _, log in ipairs(logs) do
                local f = io.open(log, "r")
                if f then
                    local size = f:seek("end")
                    f:close()
                    
                    if size > MAX_SIZE then
                        rotate_log(log)
                        os.execute("kill -USR1 " .. ngx.worker.pid())
                    end
                end
            end
        end
        
        local function log_rotation(premature)
            if premature then return end
            check_logs()
        end
        
        ngx.timer.every(ROTATE_INTERVAL, log_rotation)
    }
}

六、性能优化技巧

在高并发环境下,日志切割可能会成为性能瓶颈。这里分享几个优化经验:

  1. 使用ngx.timer.at代替ngx.timer.every,实现更精确的控制
  2. 将文件操作放在单独的定时器中,避免阻塞worker进程
  3. 使用ngx.thread.spawn来并行处理多个日志文件
  4. 对于超大规模部署,可以考虑将日志直接发送到syslog或ELK

这里是一个优化后的版本:

local function async_rotate(log_path)
    local co = ngx.thread.spawn(function()
        local timestamp = os.date("%Y%m%d%H%M%S")
        local ok, err = os.rename(log_path, log_path.."."..timestamp)
        if not ok then
            ngx.log(ngx.ERR, "failed to rotate log: ", err)
        end
        return ok
    end)
    
    return co
end

local function optimized_rotation()
    local logs = {
        "/var/log/openresty/access.log",
        "/var/log/openresty/error.log"
    }
    
    local threads = {}
    for _, log in ipairs(logs) do
        table.insert(threads, async_rotate(log))
    end
    
    for _, thread in ipairs(threads) do
        ngx.thread.wait(thread)
    end
    
    os.execute("kill -USR1 " .. ngx.worker.pid())
end

七、总结

OpenResty的日志切割看似简单,但要实现一个稳定可靠的方案需要考虑很多因素。通过本文介绍的几种方法,你可以根据实际需求选择合适的方案:

  1. 对于小型应用,简单的logrotate可能就足够了
  2. 中型应用建议使用Lua定时器方案
  3. 大型高并发应用则需要考虑更复杂的优化策略

无论选择哪种方案,都要记得在实际环境中充分测试,特别是要模拟高并发场景下的日志写入情况。另外,完善的监控和告警机制也很重要,这样在日志系统出现问题时能及时发现和处理。