Nginx 日志解析

2016-04-27 linux web software

在 Nginx 的统计排错过程中,通常要依赖日志,Nginx 有非常灵活的日志记录模式,每个级别的配置可以有各自独立的访问日志。

简介

其中与日志模块相关的参数如下,在 CentOS 中,日志默认保存在 /usr/local/nginx/logs 目录下。

访问日志

实际上这里的配置通过 ngx_http_log_module 实现,记录了哪些用户,哪些页面以及用户浏览器、IP 和其他的访问信息。

常见的配置项包括了 access_loglog_formatopen_log_file_cache

其中 log_format 记录了日志的记录格式;access_log 用于设置保存的路径、是否压缩、缓冲的大小等信息;而最后一个其实是对写入日志的优化。

access_log

可以将 path 设置为 syslog 将日志保存到系统日志中,不过其默认是保存到单独的日志文件中,默认的配置如下:

----- 语法格式如下
access_log path format gzip[=level] [buffer=size] [flush=time];
access_log syslog:server=address[,parameter=value] [format];
access_log off;

----- 默认配置为
access_log logs/access.log combined;

其中 gzip 表示压缩等级;buffer 用来设置内存缓存区大小;flush 为保存在缓存区中的最长时间。

log_format

其配置格式为:

log_format name string;

其中,name 表示该配置格式的名称;string 表示定义格式的内容。其中该模块中有一个默认的无需配置名称为 combined 的日志格式,相当于 apache 的 combined 日志格式。

log_format combined '$remote_addr - $remote_user  [$time_local]  '
                    ' "$request"  $status  $body_bytes_sent  '
                    ' "$http_referer"  "$http_user_agent" ';

如果 Nginx 位于负载均衡器 squid,或者 Nginx 反向代理之后,那么 web 服务器中的 $remote_addr 实际上获取的是反向代理的 IP 地址,将无法直接获取到客户端真实的 IP 地址。

为此,反向代理服务器在转发请求的 http 头信息中,可以增加 X-Forwarded-For 信息,用来记录客户端 IP 地址和客户端请求的服务器地址。

log_format  porxy  '$http_x_forwarded_for - $remote_user  [$time_local]  '
                   ' "$request"  $status $body_bytes_sent '
                   ' "$http_referer"  "$http_user_agent" ';

其中对应各个参数分别代表的值可以参考 ngx_http_log_module 中的介绍。

open_log_file_cache

对于每一条日志记录,都将是先打开文件,再写入日志,然后关闭;这样就会导致文件被频繁的打开关闭,可以使用 open_log_file_cache 设置日志文件缓存。

open_log_file_cache max=N [inactive=time] [min_uses=N] [valid=time];
open_log_file_cache off;

----- 示例
open_log_file_cache max=1000 inactive=20s valid=1m min_uses=2;

错误日志

error_log 则是记录服务器错误日志。

源码解析

在 nginx 中,会通过 ngx_errlog_module 模块用于处理 nginx 日志信息,这也是 nginx 的 core 模块之一。

在配置文件中,会通过解析 error_log 配置项将不同等级的日志信息输出到指定的文件中,当遇到该配置项是,就调用 errlog 模块的 ngx_error_log() 来解析。

ngx_error_log()

上述函数会将 error_log 配置项的值保存在 ngx_cycle->new_log 成员中,当配置文件中有多条 error_log 配置项生效时,会通过 ngx_cycle->new_log.next 成员将它们组织起来。

如果配置文件中没有 error_log 配置项,在配置文件解析完后调用 errlog 模块的 ngx_log_open_default() 将日志等级默认置为 NGX_LOG_ERR,日志文件设置为 NGX_ERROR_LOG_PATH (编译时指定)。

日志结构体

struct ngx_log_s {
    ngx_uint_t           log_level;          // 日志等级
    ngx_open_file_t     *file;               // 记录日志文件信息
    ngx_atomic_uint_t    connection;
    time_t               disk_full_time;
    ngx_log_handler_pt   handler;
    void                *data;
    ngx_log_writer_pt    writer;
    void                *wdata;
    char                *action;
    ngx_log_t           *next;
};

一般通过 ngx_log_error() 函数输出日志信息。

void ngx_cdecl ngx_log_error(ngx_uint_t level,
	ngx_log_t *log, ngx_err_t err, const char *fmt, ...)
{
    va_list  args;

    if (log->log_level >= level) { // 根据日志等级判断是否需要输出
        va_start(args, fmt);
        ngx_log_error_core(level, log, err, fmt, args);
        va_end(args);
    }
}

void ngx_log_error_core(ngx_uint_t level, ngx_log_t *log, ngx_err_t err,
    const char *fmt, va_list args)
{
	/* ... ... */

	/* 格式化日志内容 */
	last = errstr + NGX_MAX_ERROR_STR;
	p = ngx_cpymem(errstr, ngx_cached_err_log_time.data,
		       ngx_cached_err_log_time.len);
	p = ngx_slprintf(p, last, " [%V] ", &err_levels[level]);

	/* 写入日志 */
	while(log) {
		if (log->log_level < level && !debug_connection) {
			break;
		}

		if (log->writer) {
			log->writer(log, level, errstr, p - errstr);
			goto next;
		}

		if (ngx_time() == log->disk_full_time) {

			/*
			 * on FreeBSD writing to a full filesystem with enabled softupdates
			 * may block process for much longer time than writing to non-full
			 * filesystem, so we skip writing to a log for one second
			 */

			goto next;
		}

		n = ngx_write_fd(log->file->fd, errstr, p - errstr);

		if (n == -1 && ngx_errno == NGX_ENOSPC) {
			log->disk_full_time = ngx_time();
		}

		if (log->file->fd == ngx_stderr) {
			wrote_stderr = 1;
		}

next:

		log = log->next;
	}
}

原子写入

实际上,这里会存在一个疑问,多进程的 nginx 模型是怎么保证多个进程同时写入一个文件不发生数据交错呢?

一般来说,主要有如下的几种解决方案:

  • 通过锁保证只有一个进程写入,其他进程等待,但是这种情况效率太低。
  • 写日志前查看锁状态,如果已经锁定,则写入进程自己的缓冲区中,等待下次调用时同步缓冲区,这样做的好处是无需阻塞,提高了效率,但是就无法做到 log 的实时了,这样做工程中也是绝对无法接受的,一旦发生问题,将无法保证 log 是否已经被写入,因此很难定位。
  • 一个进程专门负责写 log,其他进程通过套接字或者管道将 log 内容发送给他,持续阻塞在 epoll_wait 上,直到收到信息,立即写入,但是众所周知,nginx 是调用同一个函数启动所有进程的,并没有专门调用函数启动所谓的 log 进程,除了 master 和 worker,nginx 也确实没有 log 进程存在。
  • 进程启动后,全部去竞争某个锁,竞争到该锁的 worker 执行 log worker 的代码,其余的 worker 继续运行相应程序,这个方案看上去是一个不错的方案,如果是单 worker 的话,那么就无需去使用该锁即可。

阅读源码可以发现 nginx 只有一把互斥锁,即用来避免惊群现象的 ngx_accept_mutex 锁,其余地方完全没有用到锁机制,这么做原因很简单,在工程化的代码中,盲目使用锁会造成性能的下降,这是不可以接受的。

如上,最终写入日志的是通过 ngx_log_error_core() 函数,这个函数正是用来打印错误日志的,而最终调用的实际上就是:

static ngx_inline ssize_t ngx_write_fd(ngx_fd_t fd, void *buf, size_t n)
{
    return write(fd, buf, n);
}

写入原理

如果多个进程都需要将数据添加到某一文件,那么为了保证定位和写数据这两步是一个原子操作,需要在打开文件时设置 O_APPEND 标志。

Single UNIX Specification 标准对此进行了详细的说明,内核在调用 write 前会对文件进行加锁,在调用 write 后会对文件进行解锁,这样保证了文件写入的原子性,也就无需担心数据交错的发生了。

实际上,对于不同类型的文件实现方式会有所区别。

普通文件

有三种情况可能导致文件写入失败:

  • 磁盘已满
  • 写入文件大小超出系统限制
  • 内核高速缓存区已满

遇到这三种情况怎么处理呢?

如果是使用 O_NONBLOCK 标识打开文件的话,write 会立即返回,返回值小于写入字符数这个参数,虽然写入了不完整数据,但是内核保证其写入过程的原子性,否则内核会让调用进程睡眠,直到文件重新可写,这样内核保证了写入数据的完整性,但是不保证写入的原子性。

也即,如果在打开文件时设置了 O_NONBLOCK 标识,则虽然可能写入部分数据,但是写入过程是原子性的。

Linux 系统默认使用 O_NONBLOCK 标识打开文件,而 BSD 等 unix 系统则恰恰相反。

总结

对于写入普通文件,只要文件是使用 O_NONBLOCK 标识打开的,那么就可以保证其写入的原子性,也就是说这样写入是可以接受的,但是这并不意味着这样做是靠谱的,这样做依然可能无法成功写入全部数据。

然而,nginx 并没有对返回结果进行判断,他并不关心是否写入成功,这显然是不严谨的,但是作为一个工程化项目,这是不得不进行的妥协。