Granian多Worker部署下FastAPI服务日志丢失问题排查求助
首先,你的问题核心在于多个Granian Worker进程(6个)同时操作同一个日志文件,加上自定义的轮转逻辑,引发了多进程竞争条件,导致日志丢失。我来拆解问题原因并给出针对性的解决方案:
问题根源分析
1. 多进程日志写入的句柄不一致
每个Granian Worker是独立的操作系统进程,都会初始化自己的TimedRotatingFileHandler并持有独立的日志文件句柄。当其中一个进程触发日志轮转(比如午夜):
- 它会压缩并重命名当前日志文件,然后创建新的空文件继续写入
- 但其他Worker进程的文件句柄仍然指向已被删除/重命名的旧文件
- 后续这些Worker的日志会写入到一个"幽灵文件"中——磁盘上看不到该文件(因为原文件已被删除),但进程还持有句柄,直到Worker重启才会释放,这部分日志永久丢失
2. 自定义轮转逻辑的多进程冲突
虽然你的_gzip_rotator用了原子替换来避免半拉文件,但这只解决了单个进程内的原子性。如果多个进程同时触发轮转(比如时间同步有微小偏差),会出现:
- 多个进程同时尝试压缩同一个源文件,导致文件被多次删除或压缩失败
- 部分日志写入操作被中断,直接丢失
解决方案
方案一:用Docker统一收集日志(最简单可靠)
放弃应用内的文件日志处理,直接将所有日志输出到stdout/stderr,让Docker负责日志的轮转和存储。这完全规避了多进程写文件的竞争问题。
步骤1:修改日志配置,只保留控制台输出
修改你的LoggerConfig.setup_logger方法,移除文件相关的handler:
@classmethod def setup_logger( cls, name: str = "my_app", level: int = logging.INFO, ) -> logging.Logger: logger = logging.getLogger(name) logger.setLevel(level) logger.propagate = False if logger.handlers: return logger formatter = logging.Formatter(cls._LOG_FMT) # 只保留控制台输出 stream_handler = logging.StreamHandler() stream_handler.setLevel(level) stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) return logger
步骤2:配置Docker Compose的日志轮转
在docker-compose.yml中添加日志驱动配置,让Docker自动处理轮转和压缩:
services: my-app: build: . container_name: my-app network_mode: "host" restart: unless-stopped logging: driver: "json-file" options: max-size: "100m" # 单个日志文件最大100MB max-file: "30" # 最多保留30个轮转文件 compress: "true" # 自动压缩旧日志
方案二:用QueueHandler + QueueListener实现进程安全日志
让所有Worker将日志发送到一个统一的队列,由主进程单独处理文件写入和轮转,确保只有一个进程操作日志文件。
步骤1:主进程初始化队列和Listener
在应用启动的入口(比如src.my_app.main),先启动一个QueueListener:
import logging import logging.handlers from multiprocessing import Queue from pathlib import Path from your_log_config_module import LoggerConfig # 全局日志队列(Granian的Worker会继承这个队列) log_queue = Queue(-1) def init_logging_listener(): # 创建文件Handler(复用你原有的轮转配置) log_path = Path("logs/my_app.log") log_path.parent.mkdir(parents=True, exist_ok=True) file_handler = logging.handlers.TimedRotatingFileHandler( filename=str(log_path), when="midnight", interval=1, backupCount=365, encoding="utf-8", errors="backslashreplace", delay=True, ) file_handler.suffix = "%Y-%m-%d" file_handler.namer = LoggerConfig._gzip_namer file_handler.rotator = LoggerConfig._gzip_rotator file_handler.setFormatter(logging.Formatter(LoggerConfig._LOG_FMT)) # 启动QueueListener listener = logging.handlers.QueueListener(log_queue, file_handler) listener.start() return listener # 启动应用前初始化Listener log_listener = init_logging_listener() # 然后定义FastAPI app from fastapi import FastAPI app = FastAPI() # ... 你的路由逻辑 ...
步骤2:Worker进程配置QueueHandler
修改LoggerConfig.setup_logger,让Worker使用QueueHandler发送日志到队列:
@classmethod def setup_logger( cls, name: str = "my_app", level: int = logging.INFO, log_queue=None, ) -> logging.Logger: logger = logging.getLogger(name) logger.setLevel(level) logger.propagate = False if logger.handlers: return logger formatter = logging.Formatter(cls._LOG_FMT) # 控制台输出保留(方便调试) stream_handler = logging.StreamHandler() stream_handler.setLevel(level) stream_handler.setFormatter(formatter) logger.addHandler(stream_handler) # 添加QueueHandler,将日志发送到全局队列 if log_queue is not None: queue_handler = logging.handlers.QueueHandler(log_queue) queue_handler.setFormatter(formatter) logger.addHandler(queue_handler) return logger
在Worker初始化时,传入全局的log_queue即可(Granian的Worker会继承主进程的全局变量,所以可以直接在路由或依赖中调用setup_logger(log_queue=log_queue))。
方案三:临时排查验证
如果想先确认问题是不是多进程导致的,可以临时把Granian的--workers改成1,运行一段时间观察日志是否还丢失。如果单Worker下日志正常,那100%是多进程共享日志文件的问题。
另外,可以在容器内执行lsof | grep deleted,查看是否有已被删除但仍被进程持有的日志文件——如果有,说明确实是Worker在写入已轮转的旧文件。
内容的提问来源于stack exchange,提问作者Geo48

