PAYATHON 2026

FastAPI 使用 granian 多 worker 时,如何避免日志轮转丢失?

数据小陈

结论

这份配置有多进程日志轮转冲突的风险。如果 6 个 worker 都为同一个文件创建了 TimedRotatingFileHandler,每个 worker 都会各自判断轮转时间、压缩日志、删除源文件并清理历史归档。标准库的这个 handler 不负责协调多个进程对同一文件的写入和轮转。

自定义 rotator 中的 os.replace() 只能保证单次文件替换的原子性,不能保护整个轮转过程。多 worker 共享文件值得优先排查,但仅凭配置还不能断定所有缺失记录都是由此造成的。

对于当前 Docker 部署,建议让 worker 将日志输出到 stdout 或 stderr,由容器日志系统收集。如果必须保留每天一个 .gz 文件,应由一个独立的日志写入进程负责文件写入、轮转和压缩。

为什么原子替换仍然会丢日志

假设两个 worker 同时准备轮转 logs/my_app.log,可能出现以下情况:

  1. worker A 开始读取日志并压缩时,worker B 仍在向该文件追加记录。压缩没有建立一致的快照,后续追加的内容可能未被收录。
  2. 两个 worker 用不同的临时文件完成压缩,却将结果替换到同一个归档路径。后完成的替换可能覆盖前一个归档。
  3. worker A 删除源文件后,worker B 仍持有旧文件的文件描述符。在常见的 Linux 文件系统语义下,B 可以继续写入这个已经没有目录项的文件,记录不会出现在预期路径中。
  4. 一个 worker 重新创建了活动日志文件,另一个 worker 随后执行 os.remove(source),可能删除这个新建文件。

tempfile.mkstemp() 避免了临时文件重名,os.replace() 避免目标路径出现半写入的压缩文件,但两者都不能协调这些跨进程操作。

以下配置也不能提供进程间同步:

  • if logger.handlers:只检查当前进程中该 logger 的 handler。
  • logger.propagate = False:控制日志是否向父 logger 传播。
  • delay=True:延迟打开文件。
  • Docker volume:将目录映射到宿主机,不负责协调日志轮转。

方案一:由 Docker 收集日志

如果业务没有强制要求应用自己生成每日 .gz 文件,可以用下面的配置替换原来的日志初始化代码。此示例假定 my_app logger 完全由应用管理,并且只在 worker 初始化时调用。

import logging
import sys


def setup_logger(
    name: str = "my_app",
    level: int = logging.INFO,
) -> logging.Logger:
    logger = logging.getLogger(name)
    logger.setLevel(level)
    logger.propagate = False

    for handler in list(logger.handlers):
        logger.removeHandler(handler)
        handler.close()

    handler = logging.StreamHandler(sys.stdout)
    handler.setLevel(level)
    handler.setFormatter(
        logging.Formatter(
            "%(asctime)s - %(levelname)s - "
            "pid=%(process)d - %(name)s - %(message)s"
        )
    )
    logger.addHandler(handler)
    return logger

部署修改后,重新启动 worker,确认旧的 TimedRotatingFileHandler 已移除。原代码遇到已有 handler 时会直接返回,因此仅在后面追加一个新的 StreamHandler,不能消除旧配置。

如果容器日志驱动支持读取日志,可以查看输出:

docker logs --timestamps my-app

原配置中的 logging.StreamHandler() 默认输出到 stderr。Docker 通常也会收集这一通道,因此它可以作为对照来源。

容器日志的容量、保留数量和归档策略需要另行配置。移除应用文件 handler 后,原来的 backup_count=365 和每日 gzip 策略不会继续生效。

方案二:集中到一个进程写文件

如果需要保留现有的每日轮转和 gzip 压缩,可以采用以下结构:

worker 1 ─┐
worker 2 ─┤
   ...    ├─> 进程间日志通道 ─> 单个日志写入进程
worker 6 ─┘                       └─> TimedRotatingFileHandler + gzip

分工如下:

  1. 各 worker 只发送日志记录,不直接创建文件 handler。
  2. 一个日志写入进程接收全部记录,并独占 TimedRotatingFileHandler。
  3. 原来的 namer 和 rotator 仅由这个写入进程调用。
  4. 停机时先停止日志生产,再等待待写记录处理完成,最后关闭文件 handler。

Python 的 QueueHandler 和 QueueListener 可以用于这种结构,但所有 worker 必须连接到同一个可跨进程使用的队列。如果每个 worker 都自己创建队列和 listener,仍然会产生多个文件写入者。

由于这里没有提供 granian 和 Python 的版本、worker 启动方式及初始化入口,不能直接假设模块级队列会在所有 worker 间共享。若不方便接入共享队列,可以使用独立日志接收进程和明确的 IPC 通道。

集中写入后,仍需考虑队列满、接收进程退出和停机排空等问题,否则日志可能在传输阶段丢失。

如何确认缺失发生在哪里

先在日志格式中加入 %(process)d 和 %(name)s,并使用请求 ID 标识同一次请求。然后选取缺失的请求,比较以下位置:

  • 容器收集的 stdout、stderr。
  • 当前活动日志文件。
  • 对应日期及相邻日期的 .gz 归档。

如果同一条记录出现在容器输出中,却没有出现在活动文件或归档中,应重点检查文件写入、轮转和压缩过程,并查看 stderr 是否出现 handler 的异常信息。

还可以临时使用一个 worker,在可控环境中缩短轮转周期进行对照。如果单 worker 正常,而多 worker 在轮转附近出现缺失,会进一步支持轮转竞争这一判断,但仍需核对具体的缺失记录。

如果容器输出中也找不到记录,还需要检查日志语句是否实际执行、日志级别是否过滤了记录,以及请求记录是否来自 granian 的访问日志。应用的 my_app logger 与服务器访问日志可能使用不同的配置入口。

配置时还要注意什么

logs/my_app.log 是相对路径,其位置取决于进程工作目录。只有工作目录对应 /src/my_app 时,它才会落入给出的挂载目录 /src/my_app/logs。可以通过 Path(log_file).resolve() 确认实际路径。

when="midnight" 不代表后台线程会在零点立即轮转。TimedRotatingFileHandler 在处理日志记录时检查轮转条件,跨过零点后的第一条记录可能触发轮转。

原始 Python 和 Compose 片段的缩进没有完整显示。以上分析以实际文件缩进正确、服务能够正常运行为前提。

备注:内容仅供参考。