You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

367 lines
14 KiB

""" 日志指定目录扫描以及切割, 有效文件名格式如下:
包含一个.的日志文件: [^.]+.log
其他忽略
"""
import os
import fnmatch
import shutil
import time
import datetime
import logging
import warnings
import requests
from logging import FileHandler, StreamHandler
from conf import setting
from .common import Singleton, str2int
MAXSIZE = 200 * 1024 * 1024
def check_and_rotate_logfile(dirname, logger, maxsize=10 * 1024 * 1024, maxtime=30 * 24 * 3600):
"""每周定时切换日志
NOTE: 如果日志文件大小小于10MB, 则不进行切换操作
"""
if not os.path.exists(dirname):
logger.error(f'日志检查: 日志目录({dirname})不存在, 请检查')
return
# 1. 切割日志
now = datetime.datetime.now()
date_str = '{:04d}{:02d}{:02d}{:02d}{:02d}{:02d}'.format(now.year, now.month, now.day, now.hour, now.minute, now.second)
for filename in os.listdir(dirname):
# a. 拷贝
filepath = dirname + os.sep + filename
if not os.path.isfile(filepath):
continue
if filename.count('.') > 1:
continue
if not fnmatch.fnmatchcase(filename, '*.log'):
continue
filesize = os.path.getsize(filepath)
if filesize <= maxsize: # 10MB以下文件不切割
continue
new_filepath = dirname + os.sep + filename.rsplit('.log', 1)[0] + '.' + date_str + '.log'
shutil.copyfile(filepath, new_filepath)
# b. 清空
with open(filepath, 'r+', encoding='utf8') as fd:
fd.seek(0)
fd.truncate()
# 2. 删除老日志
bretime = time.time() - maxtime # 30天前
for filename in os.listdir(dirname):
filepath = dirname + os.sep + filename
if not os.path.isfile(filepath):
continue
if fnmatch.fnmatchcase(filename, '*.tar.gz'): # 清理压缩包
os.remove(filepath)
continue
if not fnmatch.fnmatchcase(filename, '*.log'):
continue
if filename.count('.') <= 1:
continue
try:
if os.path.getmtime(filepath) <= bretime:
os.remove(filepath)
logger.exception(f'日志检查: 删除日志{filepath}成功')
except Exception as e:
logger.exception(f'日志检查: 删除日志{filepath}失败, msg: {e}')
def join_multiple_files(logger, filepaths, destfilepath, needsize, date_str):
"""join_multiple_files: 从filepaths中提取needsize大小的数据并append到destfilepath中
:param filepaths: 按照时间从老到新的有序文件路径列表
:param destfilepath: 目标文件
:param needsize: 需要提取的数据大小
:param date_str: 时间字符串
"""
_min_size = 1024 * 1024
for filepath in reversed(filepaths):
# 获取源文件(截取拷贝等操作)
need_delete = False
filesize = os.path.getsize(filepath)
if filesize == needsize:
_tmp_filepath = filepath
needsize = 0
elif filesize < needsize:
_tmp_filepath = filepath
needsize = int((needsize - filesize) / _min_size)
else:
_tmp_filepath = filepath + f'.tmp{date_str}'
skip_block = int((filesize - needsize) / _min_size) # 跳过块, 没块大小1MB
cmd = f'dd if={filepath} of={_tmp_filepath} skip={skip_block} ibs={_min_size} obs={_min_size}'
needsize = 0
val = os.system(cmd)
if val:
cmd = f'dd if={filepath} of={_tmp_filepath} skip={skip_block} ibs=1M obs=1M'
val = os.system(cmd)
if val:
msg = f'执行命令:{cmd}失败, code: {val}'
logger.error(msg)
return False, msg
need_delete = True
# 拼接和删除临时文件
try:
cmd = f'cat {_tmp_filepath} >> {destfilepath}'
val = os.system(cmd)
if val:
msg = f'执行命令:{cmd}失败, code: {val}'
logger.error(msg)
return False, msg
finally:
if need_delete:
os.remove(_tmp_filepath)
# 判断needsize大小
logger.info(f'成功拷贝文件:{filepath}中的内容到:{destfilepath}')
if needsize <= 0:
break
return True, destfilepath
def zip_and_transform_logfile(dirname, logger, fileprefix='yanei', minsize=MAXSIZE, tarfile=None):
"""zip_and_transform_logfile, 打包获取日志文件中最近20MB(传入参数)的数据并返回
:param dirname: 日志目录
:param logger: 日志记录对象
:param fileprefix: 日志文件前缀
:param minsize: 截取的日志文件大小
:param tarfile: 如果该值存在, 则会
NOTE: 该函数仅仅适用于Linux系统
"""
if not os.path.exists(dirname):
msg = f'日志检查: 日志目录({dirname})不存在, 请检查'
logger.error(msg)
return False, msg
# 获取当前所有server.log日志以及自动备份的日志: yanei.20211026160000.log
can_cutout_filepaths = []
current_filename = f'{fileprefix}.log' # 当前正在记录日志的文件
current_filepath = dirname + os.sep + current_filename
for filename in os.listdir(dirname):
filepath = dirname + os.sep + filename
if not os.path.isfile(filepath):
continue
if fnmatch.fnmatchcase(filename, '*.tar.gz'):
continue
if not fnmatch.fnmatchcase(filename, f'{fileprefix}*'):
continue
if filename == current_filename:
continue
can_cutout_filepaths.append(filepath)
_can_cutout_filepaths = []
for filepath in can_cutout_filepaths:
try:
x = str2int(filepath.split('.')[1:2][0])
if not isinstance(x, int):
api_logger.error(f'发现一个未知格式的文件:{filepath}, 忽略该文件')
continue
except Exception as msg:
api_logger.error(f'发现一个未知格式的文件:{filepath}, 忽略该文件')
else:
_can_cutout_filepaths.append(filepath)
can_cutout_filepaths = _can_cutout_filepaths
can_cutout_filepaths = sorted(can_cutout_filepaths, key=lambda x: str2int(x.split('.')[1:2][0]))
# 截取并拼接文件
current_filesize = os.path.getsize(current_filepath)
_min_size = 1024 * 1024
now = datetime.datetime.now()
date_str = '{:04d}{:02d}{:02d}{:02d}{:02d}{:02d}'.format(now.year, now.month, now.day, now.hour, now.minute, now.second)
save_filename = f'server.log.tmp.{date_str}'
save_filepath = dirname + os.sep + save_filename
if current_filesize > minsize:
skip_block = int((current_filesize - minsize) / _min_size) # 跳过块, 没块大小1MB
cmd = f'dd if={current_filepath} of={save_filepath} skip={skip_block} ibs={_min_size} obs={_min_size}'
val = os.system(cmd)
if val:
cmd = f'dd if={current_filepath} of={save_filepath} skip={skip_block} ibs=1M obs=1M'
val = os.system(cmd)
if val:
msg = f'执行命令:{cmd}失败, code: {val}'
logger.error(msg)
return False, msg
logger.info(f'日志文件足够大, 截取:{current_filepath}{save_filepath}中成功')
elif current_filesize == minsize:
save_filepath = shutil.copyfile(current_filepath, save_filepath)
logger.info(f'日志文件大小刚好, 拷贝:{current_filepath}{save_filepath}中成功')
else:
save_filepath = shutil.copyfile(current_filepath, save_filepath)
logger.info(f'日志文件大小较小, 先拷贝:{current_filepath}{save_filepath}中成功')
needsize = int((minsize - current_filesize) / _min_size)
succ, msg = join_multiple_files(logger, can_cutout_filepaths, save_filepath, needsize, date_str)
if not succ:
return False, msg
# 打包, 其中压缩包文件会在定时任务函数中清理
tar_filename = f'YANEILog_.{date_str}.tar.gz' if not tarfile else tarfile
tar_filepath = [dirname, tar_filename]
try:
cmd = f'cd {dirname} && tar zcf {tar_filename} {save_filename}'
val = os.system(cmd)
if val:
msg = f'执行命令:{cmd}失败, code: {val}'
logger.error(msg)
return False, msg
finally:
os.remove(save_filepath)
return True, tar_filepath
class YaneFilter(logging.Filter):
"""自定义logging过滤器, 通过redis或者mysql获取当前记录的最新日志level, 根据
该level决定是否记录某一条日志
"""
def filter(self, record):
return super(YaneFilter, self).filter(record)
class YaneiErrorFilter(logging.Filter):
"""自由控制: 记录error以上的日志"""
def filter(self, record):
try:
return record.levelno >= logging.ERROR
except Exception:
return super(YaneiErrorFilter, self).filter(record)
@Singleton
class YaneiLoggerGenerator:
DEFAULT_LOG_FILE = 'server.log' # 默认日志存储文件
DEFAULT_ERROR_LOG_FILE = 'error.log'
""" 日志logger生成工具
NOTE: 非多进程安全, 并发量高的情况下可能造成日志丢失
@默认格式: 日志等级 时间 日志所在文件名:日志所在行号 - 固定前缀 - 日志信息
@生成logger或获取某一个logger:
from app.common.log import g_logger_generator
# 表示获取api日志对象, 日志会记录到api.log, 固定前缀为API
app.apiLogger = g_logger_generator.get_logger('api', 'api.log', prefix='API')
# 表示获取切换日志对象, 日志会记录到默认文件server.log, 固定前缀为SWITCH
app.celeryLogger = g_logger_generator.get_logger('switch', prefix='SWITCH')
# 表示获取文件同步日志对象, 日志会记录到默认文件server.log, 固定前缀为DELAY
app.delayLogger = g_logger_generator.get_logger('delay', prefix='DELAY')
"""
def __init__(self):
self._loggers = {} # 当前进程中logger集: name -> logger
self.common_fmt_prefix = '%(levelname)s %(process)d:%(thread)d %(asctime)s %(filename)s:%(lineno)s'
# 关闭requests, urllib3告警日志
requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning)
warnings.simplefilter('ignore', ResourceWarning)
def _formatter(self, fmt, prefix):
return logging.Formatter(fmt + f' - {prefix} - ' + '%(message)s')
def update_file_mode(self, filepath, mode=0o666):
if os.path.exists(filepath):
status = os.stat(filepath)
if (status.st_mode & mode) == mode:
return
old_umask = os.umask(0)
with open(filepath, 'w', encoding='utf8') as fd:
fd.write('.')
os.chmod(filepath, mode)
os.umask(old_umask)
def get_logger(self, name, filename=None, level=logging.INFO, prefix='COMMON', stream=True):
"""get_logger, 获取name指定的日志句柄对象
:param name:logger名称
:param filename:日志存储的文件名
:param level:日志记录等级
:param prefix:日志记录前缀, 用以区分同一个日志文件中的不同日志信息
:param stream:是否日志打印到stdout, 其中celery异步任务中的日志会自动重定向到标准输出中, 不需要重新设置
如果本地开启调试, 则可以传递该值为True, 确保在本地开发时将日志打印到stdout
@return: 返回一个{name}Logger的logger对象
"""
if name in self._loggers:
return self._loggers[name]
if not os.path.exists('logs'):
os.makedirs('logs')
filename = self.DEFAULT_LOG_FILE if not filename else filename
my_formatter = self._formatter(self.common_fmt_prefix, prefix)
streamHandler = StreamHandler()
streamHandler.setFormatter(my_formatter)
_filename = f'logs{os.sep}{filename}'
self.update_file_mode(os.path.join(setting.BASE_DIR, _filename))
logHandler = FileHandler(_filename)
logHandler.suffix = '%Y-%m-%d.%M-%S.log'
logHandler.setFormatter(my_formatter)
my_logger = logging.getLogger(name)
if not my_logger.handlers:
my_logger.addHandler(logHandler)
my_logger.setLevel(level)
if stream:
my_logger.addHandler(streamHandler)
self._loggers[name] = my_logger
return my_logger
def update_default_logger(self, logger, filename=None, level=logging.INFO, prefix='COMMON', clear=False, stream=True):
"""更新某一个logger的日志配置, 参数参考get_logger"""
if logger.name not in self._loggers:
self._loggers[logger.name] = logger
filename = self.DEFAULT_LOG_FILE if not filename else filename
# NOTE: 删除logger默认的handler, 如果需要则删除下面功能
if clear:
for old_handler in logger.handlers:
logger.removeHandler(old_handler)
my_formatter = self._formatter(self.common_fmt_prefix, prefix)
streamHandler = StreamHandler()
streamHandler.setFormatter(my_formatter)
if stream:
logger.addHandler(streamHandler)
_filename = f'logs{os.sep}{filename}'
self.update_file_mode(os.path.join(setting.BASE_DIR, _filename))
logHandler = FileHandler(_filename)
logHandler.suffix = '%Y-%m-%d.%M-%S.log'
logHandler.setFormatter(my_formatter)
logger.addHandler(logHandler)
# 增加一个单独记录错误日志的handler(不需要重定向到标准输出)
_filename = f'logs{os.sep}{self.DEFAULT_ERROR_LOG_FILE}'
self.update_file_mode(os.path.join(setting.BASE_DIR, _filename))
errorhandler = FileHandler(_filename)
errorhandler.suffix = '%Y-%m-%d.%M-%S.log'
errorhandler.setFormatter(my_formatter)
errorhandler.addFilter(YaneiErrorFilter())
logger.addHandler(errorhandler)
logger.setLevel(logging.INFO)
return logger
__g_logger_generator = YaneiLoggerGenerator()
api_logger = __g_logger_generator.get_logger('api', filename='api.log', level=setting.YANEI_LOG_LEVEL, prefix='API', stream=True)
logger = __g_logger_generator.get_logger('server', level=setting.YANEI_LOG_LEVEL, prefix='SERVER', stream=True)