Django 維運必看:如何設計高效率的 Log 分層與自動歸檔架構?

Django 維運必看:如何設計高效率的 Log 分層與自動歸檔架構?

在軟體開發中,錯誤並非意外,而是必然。無論是邏輯異常、第三方 API 崩潰,還是無聲的流程阻塞,當系統失靈時,開發者的核心挑戰在於:如何從海量訊息中,秒速鎖定事故現場?

本文將深入探討 Django 的強大 Logging 機制,示範如何透過模組化設計與 Trace ID 追蹤,將混亂的程式執行過程轉化為透明、可控的事件紀錄,讓錯誤不再是難以追蹤的黑盒子。

一、log (日誌) 簡介

Django 的 log 整體架構分為四個核心元件:

元件職責譬喻
Logger接收訊息的入口郵局櫃檯
Filter決定哪些訊息該通過安全檢查員
Handler決定訊息送到哪 (檔案/螢幕)運輸工具
Formatter設定訊息的外觀格式信件格式
  1. logger:日誌的入口點,定義名稱與等級(如 INFO, ERROR)
  2. handler:決定 Log 要送去哪裡(console、file、email、第三方服務)
  3. formatter:決定 Log 的格式(時間、模組、訊息等)
  4. filter:額外條件控制,例如只記某個模組的錯誤

其中 formatter 應該是跟我們看到的訊息最息息相關的設定。通常一條 Log 訊息的組成如下:

2025-09-08 14:26:29 | WARNING | django.request | trace_id=adaaf8b8-1345-4415-bfd2-c3640ae5a55a | Unauthorized: /api/analysis/rbac/report/
%(asctime)s | %(levelname)s | %(name)s | trace_id=%(trace_id)s | %(message)s

由左到右分別是:

  • asctime:流程執行的時間
  • levelname:流程的層級 (DEBUG、INFO…)
  • name:流程從哪裡執行 (django.server)
  • trace_id:流程的識別碼,用來判斷哪幾條訊息是屬於同一個流程的。這並不是 Django 預設欄位,所以後面我們要自己加上去。
  • message:訊息的細節內容。

後面的則是自訂的欄位,可以在 settings.py 調整。

而在 log 訊息是有分層級的,各層級的說明大致如下:

等級數字名稱說明
DEBUG10除錯資訊開發階段的細節追蹤,例如 ORM 轉換的 SQL 語法、檔案操作流程等,開發者通常不會自訂,除非開啟 Debug 模式。
INFO20一般資訊系統正常運作的紀錄,如收到 webhook 或 API 成功回應。適用於記錄正常流程的里程碑、稽核追蹤、系統健康檢查。
WARNING30警告非致命但可能異常的狀況,如重試、過期 token、使用備援方案、沒有權限等。表示系統遇到異常但已處理,可用於記錄非預期行為。
ERROR40錯誤發生錯誤但系統仍可繼續運作,如 API 呼叫失敗、驗證錯誤等。表示某個操作失敗,可能需要人工介入,可搭配例外處理程式(exception handler)使用。
CRITICAL50致命錯誤系統無法繼續運作,如資料庫連線失敗、配置錯誤、系統崩潰。通常會觸發警報,應立即通知維運人員,可搭配警報系統(如 Sentry、Prometheus)使用。

接下來,我們要快速從零開始建立一個 Django 專案,然後一樣從零開始,試著在系統配置 Log 的設定。

二、快速建立一個 Django 專案

可以參考 如何用 Django Channels 與 Celery 建立可保存訊息的即時聊天系統 建立 Django 專案的說明,這邊就快速帶過指令:

  1. 建立專案資料夾:mkdir <你要建立資料夾的路徑>
  2. 安裝 uv
    • Windows (Powershell):irm https://astral.sh/uv/install.ps1 | iex
    • Mac/Linux:curl -LsSf https://astral.sh/uv/install.sh | sh
  3. 安裝最新版 Python:uv python install
  4. 建立一個 Python 專案:uv init <專案名稱>
  5. 執行 uv sync,自動產生專案的虛擬環境
  6. 執行 main.py:uv run main.py,然後刪掉 main.py
  7. 安裝 Django 相關套件:uv add django
  8. 建立 Django 專案結構:uv run django-admin startproject <專案名>
  9. Django 專案的外層和內層應該各有一個以 <專案名> 命名的資料夾,將外層改名成 src,然後進入 src
  10. 生成資料庫遷移檔案:uv run manage.py makemigrations
  11. 執行資料庫遷移:uv run manage.py migrate
  12. 設定 superuser:uv run manage.py createsuperuser
  13. 運行伺服器:uv run manage.py runserver
  14. 這時我們可以用剛才建立的 superuser 登入 admin 後台,網址是 localhost:8000/admin。

只要看到這個畫面,就可以確定 Django 專案建立成功了!

三、簡單加入一個功能

我們快速幫這個專案加入一個功能:可以讀取 zip 檔,並進行解壓縮和分類的 API。可以將程式碼複製貼上到你的專案,有興趣再自行研究,或是有機會再寫一篇文章說明:

  1. 首先建立一個 Django 應用程式,命名為 file:uv run manage.py startapp file
  2. 然後這個 API 是以 RESTful 風格設計的,所以要安裝 DRF (Django RESTful Framework)套件:uv add djangorestframework
  3. 按照註解的檔案路徑,將程式碼貼到對應的檔案,沒有檔案可以自行建立:
# /src/file/views.py
# 負責處理檔案上傳的 API 請求與回應。

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from .serializers import UploadSerializer
from .services import FileProcessingService

class FileUploadView(APIView):
    """
    提供 ZIP 檔案上傳與自動解壓功能的 View。
    """

    def post(self, request, *args, **kwargs):
        """
        處理 POST 請求,驗證 ZIP 檔案後調用服務進行解壓。
        """
        serializer = UploadSerializer(data=request.data)
        if serializer.is_valid():
            uploaded_file = serializer.validated_data['file']
            try:
                FileProcessingService.process_zip(uploaded_file)
                return Response(
                    {"message": "檔案上傳並解壓成功"}, 
                    status=status.HTTP_201_CREATED
                )
            except Exception as e:
                return Response(
                    {"error": f"處理 ZIP 檔案時發生錯誤: {str(e)}"}, 
                    status=status.HTTP_400_BAD_REQUEST
                )
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
# /src/file/services.py
# 負責處理 ZIP 檔案解壓與檔案分類存儲的業務邏輯。

import os
import zipfile
import io
from abc import ABC, abstractmethod
from django.conf import settings

class StorageStrategy(ABC):
    """
    檔案存儲策略的抽象基底類別。
    """
    @abstractmethod
    def get_storage_path(self, filename: str) -> str:
        """
        根據檔名返回目標存儲路徑。
        """
        pass

class JpgStorageStrategy(StorageStrategy):
    """
    處理 JPG 檔案的存儲策略。
    """
    def get_storage_path(self, filename: str) -> str:
        return os.path.join(settings.MEDIA_ROOT, 'jpg', filename)

class PdfStorageStrategy(StorageStrategy):
    """
    處理 PDF 檔案的存儲策略。
    """
    def get_storage_path(self, filename: str) -> str:
        return os.path.join(settings.MEDIA_ROOT, 'pdf', filename)

class PngStorageStrategy(StorageStrategy):
    """
    處理 PNG 檔案的存儲策略。
    """
    def get_storage_path(self, filename: str) -> str:
        return os.path.join(settings.MEDIA_ROOT, 'png', filename)

class DefaultStorageStrategy(StorageStrategy):
    """
    處理其他未知類型檔案的預設存儲策略。
    """
    def get_storage_path(self, filename: str) -> str:
        return os.path.join(settings.MEDIA_ROOT, 'others', filename)

class FileProcessingService:
    """
    協調 ZIP 解壓與策略選取的服務類別。
    """

    _strategies = {
        '.jpg': JpgStorageStrategy(),
        '.jpeg': JpgStorageStrategy(),
        '.pdf': PdfStorageStrategy(),
        '.png': PngStorageStrategy(),
    }

    @staticmethod
    def process_zip(zip_file) -> None:
        """
        解析 ZIP 檔案並根據副檔名分發至對應存儲路徑。
        """
        with zipfile.ZipFile(zip_file, 'r') as zf:
            for file_info in zf.infolist():
                if file_info.is_dir():
                    continue

                filename = os.path.basename(file_info.filename)
                _, ext = os.path.splitext(filename.lower())

                strategy = FileProcessingService._strategies.get(ext, DefaultStorageStrategy())
                target_path = strategy.get_storage_path(filename)

                # 確保目錄存在並寫入檔案
                os.makedirs(os.path.dirname(target_path), exist_ok=True)
                with zf.open(file_info) as source, open(target_path, 'wb') as target:
                    target.write(source.read())
# /src/file/serializers.py
# 負責驗證上傳的 ZIP 檔案。

from rest_framework.serializers import Serializer, FileField, ValidationError

class UploadSerializer(Serializer):
    """
    處理 ZIP 檔案上傳驗證的序列化器。
    """
    file = FileField(help_text="請上傳 ZIP 格式的壓縮檔")

    def validate_file(self, value):
        """
        驗證檔案是否為 zip 格式。
        """
        if not value.name.endswith('.zip'):
            raise ValidationError("僅支援 ZIP 格式檔案。")
        return value
# /src/file/urls.py
# file APP 內部的路由。
from django.urls import path
from .views import FileUploadView

urlpatterns = [
    path('upload/', FileUploadView.as_view(), name='file-upload'),
]

# /src/project_logs_demo/urls.py
# 系統的路由。
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include('file.urls')),
]
# /src/project_logs_demo/settings.py
# 系統設定檔

...

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'file',
]

...

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
  1. 貼上後,可以執行 uv run manage.py runserver 啟動伺服器,在 Postman 測試這支 API 看看:
    • 欄位驗證:僅支援 ZIP 格式檔案
    • 成功處理 ZIP 檔案

四、實戰進階:實作 Trace ID 與 Middleware 實現請求全鏈路追蹤

雖然是執行成功了,但執行過程中到底發生了什麼事,現在的我們並不曉得。儘管有對 zip 以外的檔案進行欄位驗證,但萬一之後在處理其他特殊類型檔案時,發生了預期之外的錯誤 (500 Internal Server Error),我們要怎麼知道是哪一個環節引發的錯誤?如果要檢查數天前是否有引發相同的錯誤,終端機上的紀錄是不會留著的,到時候要怎麼除錯?

留下可追溯的詳細報告不只是為了排錯,更能協助效能監控、安全稽核與系統維運。這就是 log 的重要性與價值。

一般來說,我們會在 Django 的程序接收到請求,交給 View 處理前,給這個請求一組具備唯一性的 Unique ID;目的是讓接下來的 log 產生前,可以加上這組 ID,我們就可以知道這條 log 是在哪個請求處理時產生的

而在「程序接收到請求,交給 View 處理前」的這個階段叫做 “middleware” (中間層),我們可以在這一個階段對輸入的請求 (request) 和輸出的回應 (response) 進行格式處理,或是插入資料;因此我們這邊要做的,就是在 middleware 產生一組 Trace ID 到請求裡面,供後續 log 使用。

因此,我們要在 Django 的專案資料夾 (project_log_demo, 與 settings.py 同層) 建立兩個檔案:處理 Middleware 行為的 middleware.py 與 Trace ID 相關操作的 trace_id.py

# /src/project_log_demo/trace_id.py
# 負責在日誌中注入與管理唯一追蹤 ID (Trace ID)。

import uuid
import logging
from asgiref.local import Local

# 使用 Local 存儲每個線程/協程的 Trace ID
_storage = Local()

def get_trace_id():
    """
    獲取當前請求的 Trace ID,如果不存在則生成一個。
    """
    return getattr(_storage, 'trace_id', 'no-trace-id')

def set_trace_id(trace_id=None):
    """
    設定當前請求的 Trace ID。
    """
    if trace_id is None:
        trace_id = str(uuid.uuid4())
    _storage.trace_id = trace_id
    return trace_id

def clear_trace_id():
    """
    清除當前請求的 Trace ID。
    """
    if hasattr(_storage, 'trace_id'):
        del _storage.trace_id

class TraceIDFilter(logging.Filter):
    """
    日誌過濾器,將 Trace ID 注入到每筆日誌紀錄中。
    """
    def filter(self, record):
        record.trace_id = get_trace_id()
        return True
# /src/project_log_demo/middleware.py
# 負責處理每個請求的 Trace ID 生命週期。

from .trace_id import set_trace_id, clear_trace_id

class TraceIDMiddleware:
    """
    中介軟體,用於在請求開始時生成 Trace ID 並在結束時清除。
    """
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # 從請求標頭中嘗試獲取傳入的 trace_id,否則生成新的
        trace_id = request.headers.get('X-Trace-ID')
        trace_id = set_trace_id(trace_id)

        response = self.get_response(request)

        # 在回應標頭中附帶 Trace ID 以便追蹤
        response['X-Trace-ID'] = trace_id

        # 請求結束,清除存儲
        clear_trace_id()

        return response

在 Django 如果有添加 Middleware 的設定,要去 settings.py 註冊,就像新的 app 一樣;只是 app 是寫在 INSTALLED_APPS,Middleware 要寫在 MIDDLEWARE

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'project_log_demo.middleware.TraceIDMiddleware',    # 這裡
    ...
]

現在,可以開始添加 log 的設定了。在 settings.py 輸入以下的內容:

# LOGGING Configuration
import sys
import os
from datetime import datetime
from colorlog import ColoredFormatter

# 建立 logs 目錄(依日期分資料夾)
today_str = datetime.now().strftime("%Y-%m-%d")
LOG_BASE_DIR = BASE_DIR / 'logs' / today_str
LOG_BASE_DIR.mkdir(parents=True, exist_ok=True)

# 設定 logs 訊息格式
LOG_FORMAT = "%(log_color)s%(asctime)s | %(levelname)s | %(name)s | trace_id=%(trace_id)s | %(message)s"
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'filters': {
        'trace_id': {
            '()': 'project_log_demo.trace_id.TraceIDFilter',
        },
    },
    'formatters': {
        'standard': {
            'format': "%(asctime)s | %(levelname)s | %(name)s | trace_id=%(trace_id)s | %(message)s",
            'datefmt': DATE_FORMAT
        },
        'colored': {
            '()': ColoredFormatter,
            'format': LOG_FORMAT,
            'datefmt': DATE_FORMAT,
            'log_colors': {
                'DEBUG':    'blue',
                'INFO':     'black,bg_green',
                'WARNING':  'black,bg_yellow',
                'ERROR':    'white,bg_red',
                'CRITICAL': 'white,bg_purple',
            },
        },
    },
    'handlers': {
        'console': {
            'level': 'DEBUG',
            'class': 'logging.StreamHandler',
            'formatter': 'colored',
            'filters': ['trace_id'],
            'stream': sys.stdout,
        },
        'system_file': {
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': LOG_BASE_DIR / 'system.log',
            'when': 'midnight',
            'backupCount': 30,
            'encoding': 'utf-8',
            'formatter': 'standard',
            'filters': ['trace_id'],
        },
        'audit_file': {
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': LOG_BASE_DIR / 'audit.log',
            'when': 'midnight',
            'backupCount': 30,
            'encoding': 'utf-8',
            'formatter': 'standard',
            'filters': ['trace_id'],
        },
        'debug_file': {
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': LOG_BASE_DIR / 'debug.log',
            'when': 'midnight',
            'backupCount': 30,
            'encoding': 'utf-8',
            'formatter': 'standard',
            'filters': ['trace_id'],
            'level': 'DEBUG',
        },
    },
    'loggers': {
        'django': {
            'handlers': ['console', 'debug_file'],
            'level': 'DEBUG',
            'propagate': True,
        },
        'app.system': {
            'handlers': ['console', 'system_file', 'debug_file'],
            'level': 'INFO',
            'propagate': False,
        },
        'audit': {
            'handlers': ['console', 'audit_file'],
            'level': 'INFO',
            'propagate': False,
        },
    },
    'root': {
        'handlers': ['console', 'debug_file'],
        'level': 'INFO',
    },
}

我們逐一解析這些設定:

  1. 我們這邊想將 log 依照日期歸檔,並分成使用者操作 (audit)、系統流程 (system) 與 Django 自動產生 (debug) 三種類型。理想上的資料夾結構會長這樣: src/logs > 2026-01-29 > audit.log | debug.log | system.log ,所以 LOG_BASE_DIR 就是我們自己定義,要儲存 log 的日期資料夾。
  2. 我們想在 log 訊息自訂 Trace ID,所以要新增一個 LOG_FORMAT 定義訊息格式;而時間格式我們也想要自訂,所以也寫了 DATE_FORMAT。

接下來就進入 log 本身的設定了。可以看到除了 disable_existing_loggers 以外,最外層的四個 key 值,剛好就直接對應前面提到的四個核心:filterformatterhandlerlogger

  1. disable_existing_loggers 是讓你決定在使用自訂的 log 設定時,要不要把 Django 內建的 log 設定停用?這邊我們選 false,因為我們只有一點點想自訂的配置,大部分還是要沿用 Django 的內建配置。
  2. filter:掌控了自訂的訊息欄位
    由於我們自行添加了 trace_id,所以就要在 filter 說明這個欄位的管理方法寫在哪個檔案。
  3. formatteer:掌控了訊息的格式
    standard 設定訊息最基礎的純文字格式
    colored 用來將 log 訊息添加顏色。() 指定使用的套件,寫入我們使用的 ColoredFormatter;每一個 Log 層級的訊息要以什麼顏色顯示,就是在這邊設定。
  4. handler:掌控了訊息的輸出位置 (日誌的出口)
    這邊我們定義了四個出口:
    • console 就是終端機螢幕(stdout),讓開發者當下可以直接看到。同時我們在 level 設定 DEBUG 以上的訊息才顯示 (就是所有 log 訊息的意思)、formatter 加上色彩設定、filter 加上 trace_id。
    • system_file / audit_file / debug_file 則是為了達到前面提到的分層設計,自行區隔的輸出模式。
      除了輸出路徑 (filename) 以外,設計都非常相似。它們都使用了 TimedRotatingFileHandler,並設定 'when': 'midnight'backupCount: 30,在每天午夜(midnight)會自動把舊的日誌改名備份,並開一個新檔案,且最多幫你留 30 天,避免硬碟爆掉。
  5. logger:掌控了訊息的輸出來源 (日誌的入口)
    • django 是系統預設的 logger,會負責搬運 Django 框架本身的訊息(如啟動資訊、錯誤等)。門檻設為 DEBUG,所以再小的細節它都會抓。
    • app.systemaudit 都是我們為了區隔來源而自訂的 logger;
      前者負責搬運你寫的應用程式邏輯。門檻是 INFO,所以它會忽略微小的除錯資訊,只記錄重要動作。
      後者專門處理「稽核」日誌(例如誰登入了、誰刪了資料)。這通常是非常重要且需要獨立存放的紀錄。
    • root 是「保底」的記錄器。如果某個程式沒被上面三個認領,就會由它統一處理。

最後,我們就可以在程式使用 log 訊息:

# /src/file/services.py
# 負責處理 ZIP 檔案解壓與檔案分類存儲的業務邏輯。

...

import logging

...

# 初始化 Loggers
logger = logging.getLogger('app.system')
audit_logger = logging.getLogger('audit')

...

class FileProcessingService:
    """
    協調 ZIP 解壓與策略選取的服務類別。
    """

    ...

    @staticmethod
    def process_zip(zip_file) -> None:
        """
        解析 ZIP 檔案並根據副檔名分發至對應存儲路徑。
        """
        logger.info(f"開始處理 ZIP 檔案: {zip_file.name}")

        with zipfile.ZipFile(zip_file, 'r') as zf:
            for file_info in zf.infolist():
                if file_info.is_dir():
                    continue

                ...

                logger.debug(f"檔案 {filename} 副檔名為 {ext},選取策略: {strategy.__class__.__name__}")

                # 確保目錄存在並寫入檔案
                os.makedirs(os.path.dirname(target_path), exist_ok=True)
                with zf.open(file_info) as source, open(target_path, 'wb') as target:
                    ...

                audit_logger.info(f"檔案已儲存至: {target_path}")

        logger.info(f"ZIP 檔案處理完成: {zip_file.name}")

可以看到 log 確實有照著我們設定的顏色去做變化:

就算只看 log 訊息,任何人都可以對程式的執行流程有初步了解。
這對於需要接手維護程式的工程師來說,是很重要的指引。

五、常見(FAQ)

Q1:為什麼在 Django 中需要自訂 Trace ID?

在高併發環境下,多個請求會交織在一起。透過 Trace ID,開發者可以過濾出特定請求的所有 Log 片段,解決錯誤追蹤的斷層問題。

Q2:TimedRotatingFileHandler 的好處是什麼?

它可以自動按時間分割日誌,並透過 backupCount 自動清理舊檔案,防止伺服器硬碟空間被無限增長的日誌檔撐爆。

六、總結

在這篇文章,我們詳細介紹了關於 log 的結構、Django 的 log 配置,以及結合 Trace ID 與 colorlog ,讓不同層級的訊息可以得到更醒目的提示,將系統的執行情況一覽無遺。

如果想更加深入了解 Django 的 log 設置,可以參考官方文件 的說明。

Loading

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *