在 Nginx 的统计排错过程中,通常要依赖日志,Nginx 有非常灵活的日志记录模式,每个级别的配置可以有各自独立的访问日志。
简介
其中与日志模块相关的参数如下,在 CentOS 中,日志默认保存在 /usr/local/nginx/logs
目录下。
访问日志
实际上这里的配置通过 ngx_http_log_module 实现,记录了哪些用户,哪些页面以及用户浏览器、IP 和其他的访问信息。
常见的配置项包括了 access_log
、log_format
、open_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 并没有对返回结果进行判断,他并不关心是否写入成功,这显然是不严谨的,但是作为一个工程化项目,这是不得不进行的妥协。