# Nginx / Lua 请求与响应日志配置整理

整理日期：2026-08-10  
环境：Linux、宝塔面板、Nginx 1.30.2、PHP-FPM、`lua_nginx_module`

> 安全说明：原聊天中出现的 Token、设备编号等敏感值未在本文中原样保留。

## 1. 原始需求

部署在宝塔 Nginx 上的网站需要记录完整的请求与响应信息，要求：

- GET、POST 等各种 HTTP 方法均记录。
- 200、400、404、405、500、502 等成功或失败状态均记录。
- 请求信息与响应信息写在同一条 JSON 日志中。
- 记录请求头、请求体、响应状态、响应体、耗时和上游信息。
- 请求体和响应体只保存指定字节数，超过后截断。
- 日志保持明文，中文不能因为字节截断而破坏 JSON。
- 使用固定的 `current.log` 和 Nginx 日志缓冲提高性能。
- 每天自动轮转历史日志，不压缩。

## 2. 核心结论

原生 Nginx 可以记录请求元数据、响应状态、大小和耗时，但没有原生的 `$response_body` 变量，不能直接把完整响应正文写入访问日志。

当前宝塔 Nginx 编译参数包含：

```text
--add-module=/www/server/nginx/src/ngx_devel_kit
--add-module=/www/server/nginx/src/lua_nginx_module
```

因此不需要另外安装 OpenResty，可以使用：

```nginx
access_by_lua_block
body_filter_by_lua_block
```

实现请求体和响应体截取。

推荐作用域：

- `map` 和 `log_format` 放在主 `nginx.conf` 的 `http {}` 中。
- 两段 Lua 和 `access_log` 只放到需要采集的网站 `server {}` 中。
- 不要把 Lua 正文采集放在 `http {}` 中，否则所有网站都会执行。

## 3. 主配置中的变量声明

自定义变量必须在 `log_format` 被解析前存在。不能只在网站的 `server {}` 中使用 `set` 声明，否则会出现：

```text
unknown "trace_request_body" variable
```

在 `http {}` 内、`log_format` 前增加：

```nginx
map $request_id $trace_request_body {
    default "";
}

map $request_id $trace_request_body_truncated {
    default "0";
}

map $request_id $trace_response_body {
    default "";
}

map $request_id $trace_response_body_truncated {
    default "0";
}
```

如果使用以上 `map`，应删除网站配置中重复的：

```nginx
set $trace_request_body "";
set $trace_request_body_truncated "0";
set $trace_response_body "";
set $trace_response_body_truncated "0";
```

## 4. JSON 日志格式

在 `http {}` 内定义：

```nginx
log_format full_trace escape=json
    '{'
        '"time":"$time_iso8601",'
        '"request_id":"$request_id",'
        '"host":"$host",'
        '"server_addr":"$server_addr",'
        '"client_ip":"$remote_addr",'
        '"forwarded_for":"$http_x_forwarded_for",'

        '"request_method":"$request_method",'
        '"request_uri":"$request_uri",'
        '"server_protocol":"$server_protocol",'
        '"request_length":"$request_length",'
        '"request_content_type":"$content_type",'
        '"request_content_length":"$content_length",'
        '"request_body":"$trace_request_body",'
        '"request_body_truncated":"$trace_request_body_truncated",'

        '"status":"$status",'
        '"request_time":"$request_time",'
        '"bytes_sent":"$bytes_sent",'
        '"body_bytes_sent":"$body_bytes_sent",'

        '"upstream_addr":"$upstream_addr",'
        '"upstream_status":"$upstream_status",'
        '"upstream_connect_time":"$upstream_connect_time",'
        '"upstream_header_time":"$upstream_header_time",'
        '"upstream_response_time":"$upstream_response_time",'

        '"response_content_type":"$sent_http_content_type",'
        '"response_content_length":"$sent_http_content_length",'
        '"response_content_encoding":"$sent_http_content_encoding",'
        '"response_body":"$trace_response_body",'
        '"response_body_truncated":"$trace_response_body_truncated",'

        '"request_headers":{'
            '"token":"$http_token",'
            '"deviceid":"$http_deviceid",'
            '"referer":"$http_referer",'
            '"user_agent":"$http_user_agent"'
        '}'
    '}';
```

`escape=json` 会转义双引号、换行符、反斜杠等特殊字符。

## 5. 请求体采集

下面示例保留前 4096 个原始字节。可自行修改 `local limit = 4096`。

```nginx
access_by_lua_block {
    local limit = 4096

    local function utf8_prefix(value, max_bytes)
        if not value then
            return ""
        end

        if #value <= max_bytes then
            return value
        end

        local cut = max_bytes

        while cut > 0 do
            local byte = string.byte(value, cut)

            if byte < 128 or byte >= 192 then
                break
            end

            cut = cut - 1
        end

        if cut <= 0 then
            return ""
        end

        local lead = string.byte(value, cut)
        local char_length = 1

        if lead >= 240 and lead <= 244 then
            char_length = 4
        elseif lead >= 224 and lead <= 239 then
            char_length = 3
        elseif lead >= 194 and lead <= 223 then
            char_length = 2
        end

        if cut + char_length - 1 > max_bytes then
            return string.sub(value, 1, cut - 1)
        end

        return string.sub(value, 1, max_bytes)
    end

    ngx.req.read_body()

    local body = ngx.req.get_body_data()
    local excerpt = ""

    if body then
        excerpt = utf8_prefix(body, limit)

        if #body > limit then
            ngx.var.trace_request_body_truncated = "1"
        end
    else
        local body_file = ngx.req.get_body_file()

        if body_file then
            local file = io.open(body_file, "rb")

            if file then
                local data = file:read(limit + 4) or ""
                file:close()

                excerpt = utf8_prefix(data, limit)

                if #data > limit then
                    ngx.var.trace_request_body_truncated = "1"
                end
            end
        end
    end

    ngx.var.trace_request_body = excerpt
}
```

## 6. 响应体采集

只逐块累计前 4096 字节，不会先缓存完整响应再截取。

```nginx
body_filter_by_lua_block {
    local limit = 4096

    local function utf8_prefix(value, max_bytes)
        if not value then
            return ""
        end

        if #value <= max_bytes then
            return value
        end

        local cut = max_bytes

        while cut > 0 do
            local byte = string.byte(value, cut)

            if byte < 128 or byte >= 192 then
                break
            end

            cut = cut - 1
        end

        if cut <= 0 then
            return ""
        end

        local lead = string.byte(value, cut)
        local char_length = 1

        if lead >= 240 and lead <= 244 then
            char_length = 4
        elseif lead >= 224 and lead <= 239 then
            char_length = 3
        elseif lead >= 194 and lead <= 223 then
            char_length = 2
        end

        if cut + char_length - 1 > max_bytes then
            return string.sub(value, 1, cut - 1)
        end

        return string.sub(value, 1, max_bytes)
    end

    local chunk = ngx.arg[1]
    local current = ngx.ctx.trace_response_body_raw or ""

    if chunk and chunk ~= "" then
        local remaining = limit - #current

        if remaining > 0 then
            current = current .. string.sub(chunk, 1, remaining)
            ngx.ctx.trace_response_body_raw = current

            ngx.var.trace_response_body = utf8_prefix(current, limit)
        end

        if #chunk > remaining or (remaining <= 0 and #chunk > 0) then
            ngx.var.trace_response_body_truncated = "1"
        end
    end
}
```

## 7. 目标网站访问日志

只在目标网站的 `server {}` 中启用：

```nginx
access_log /www/wwwlogs/xxxxx.com/full_trace/current.log
           full_trace
           buffer=64k
           flush=1s;
```

参数说明：

- `full_trace`：前面定义的自定义日志格式名称。
- `buffer=64k`：每个 worker 先累计日志，再批量写入磁盘。
- `flush=1s`：缓冲区未满时，最多等待约 1 秒写入。

固定文件路径可以使用日志缓冲；包含日期变量的动态日志路径不能使用该缓冲方式。

创建日志目录：

```bash
install -d -o www -g www -m 0750 \
/www/wwwlogs/xxxxx.com/full_trace
```

检查并重载：

```bash
/www/server/nginx/sbin/nginx -t &&
/www/server/nginx/sbin/nginx -s reload
```

## 8. 每日轮转且不压缩

创建配置文件：

```text
/etc/logrotate-api-full-trace.conf
```

文件必须使用 Linux LF 换行，并由 root 所有：

```bash
chown root:root /etc/logrotate-api-full-trace.conf
chmod 0644 /etc/logrotate-api-full-trace.conf
```

配置内容：

```conf
"/www/wwwlogs/xxxxx.com/full_trace/current.log" {
    daily
    rotate 30

    dateext
    dateyesterday
    dateformat -%Y-%m-%d
    extension .log

    nocompress

    missingok
    notifempty

    su www www
    create 0640 www www

    sharedscripts

    postrotate
        if [ -s /www/server/nginx/logs/nginx.pid ]; then
            NGINX_PID="$(cat /www/server/nginx/logs/nginx.pid)"

            if kill -0 "$NGINX_PID" 2>/dev/null; then
                kill -USR1 "$NGINX_PID"
            fi
        fi
    endscript
}
```

效果：

```text
full_trace/
├── current.log
├── current-2026-08-08.log
├── current-2026-08-09.log
└── current-2026-08-10.log
```

`USR1` 只要求 Nginx 重新打开日志文件，不会重启 Nginx、不会重载网站配置，也不会中断正常请求。

## 9. 宝塔计划任务

每天 00:00 执行：

```bash
LC_ALL=C LANG=C /usr/sbin/logrotate \
  -s /var/lib/logrotate-api-full-trace.status \
  /etc/logrotate-api-full-trace.conf
```

正式任务不能使用 `-d`。`-d` 是调试模式，只打印信息，不轮转文件，也不更新状态。

手工只读检查：

```bash
LC_ALL=C LANG=C /usr/sbin/logrotate \
  -d -v \
  -s /var/lib/logrotate-api-full-trace.status \
  /etc/logrotate-api-full-trace.conf
```

配置正确时应显示：

```text
Handling 1 logs
```

## 10. 已遇到的故障与处理

### 10.1 unknown trace variable

错误：

```text
unknown "trace_request_body_b64" variable
```

原因：`log_format` 位于 `http {}`，但变量只在后续 `server {}` 中使用 `set` 声明。

处理：使用 `map` 在 `http {}` 中、`log_format` 前声明变量。

### 10.2 logrotate 配置文件权限错误

错误：

```text
Potentially dangerous mode
file owner is wrong
```

处理：

```bash
chown root:root /etc/logrotate-api-full-trace.conf
chmod 0644 /etc/logrotate-api-full-trace.conf
```

### 10.3 CRLF 导致第一行无法解析

检查时出现：

```text
daily\r$
```

处理：

```bash
sed -i 's/\r$//' /etc/logrotate-api-full-trace.conf
```

确认：

```bash
sed -n '1,10l' /etc/logrotate-api-full-trace.conf
```

正确输出不应包含 `\r$`。

### 10.4 首次执行没有创建日期文件

这是正常行为：

- `logrotate` 不负责首次创建源文件。
- `create` 只在已有 `current.log` 被成功轮转后生效。
- 第一次真实执行通常只创建状态文件。
- Nginx 加载 `access_log` 配置并接收请求后，会创建和写入 `current.log`。

### 10.5 定时任务未执行

Nginx仍会继续向 `current.log` 写入，网站不会因此中断，但文件不会按天切割，长期可能写满磁盘。

## 11. 调整正文保存长度

只需分别修改两段 Lua 中的：

```lua
local limit = 4096
```

可以为请求体和响应体设置不同限制，例如：

```text
请求体：2048 字节
响应体：8192 字节
```

注意：

- 单位是字节，不是字符。
- 中文通常每个字符占 3 个 UTF-8 字节。
- `buffer=64k` 是日志写入缓冲，与正文截取长度无关。
- `client_max_body_size` 是请求上传限制，与日志截取长度无关。
- 限制越大，日志增长越快。

## 12. 安全与边界说明

- 不建议原样记录密码、Cookie、Authorization、Token、验证码和支付信息。
- `multipart/form-data` 的原始正文会包含 boundary 和 `Content-Disposition`，不会自动转换成普通键值对。
- HEAD、204 等响应本身没有响应正文。
- 客户端中断可能记录 499 和部分响应。
- TLS 握手失败尚未形成 HTTP 请求，不会进入普通访问日志。
- WebSocket 升级后的消息不是普通 HTTP response body。
- 跨越午夜的请求以及 1 秒日志缓冲可能使少量记录落在相邻日期文件中，应以 JSON 内的 `time` 字段为准。
- 单文件从 KB 增长到 GB 通常不会显著降低追加写入速度，但会增加查询、备份和磁盘管理成本。

