From 3f40b189090b734c3735ba9bc2a7242b2c494dd6 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:58:27 +0800 Subject: [PATCH] Limit and split production logs --- cmd/server/log_rotate.go | 135 +++++++++++++++++++++++++++++++++ cmd/server/logging_test.go | 76 +++++++++++++++++++ cmd/server/main.go | 69 ++++++++++++++--- config.example.yaml | 8 +- docker-compose.advanced.yml | 7 +- internal/config/config.go | 5 +- internal/config/config_test.go | 3 + 7 files changed, 287 insertions(+), 16 deletions(-) create mode 100644 cmd/server/log_rotate.go create mode 100644 cmd/server/logging_test.go diff --git a/cmd/server/log_rotate.go b/cmd/server/log_rotate.go new file mode 100644 index 0000000..ee606d8 --- /dev/null +++ b/cmd/server/log_rotate.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +const defaultLogMaxSizeMB = 20 + +type rotatingFileWriter struct { + mu sync.Mutex + path string + maxSize int64 + maxBackups int + maxAge time.Duration + file *os.File + size int64 +} + +func newRotatingFileWriter(path string, cfg config.LoggingConfig) (*rotatingFileWriter, error) { + if path == "" { + return nil, fmt.Errorf("log path required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return nil, fmt.Errorf("create log dir: %w", err) + } + maxSizeMB := cfg.MaxSizeMB + if maxSizeMB <= 0 { + maxSizeMB = defaultLogMaxSizeMB + } + w := &rotatingFileWriter{ + path: path, + maxBackups: cfg.MaxBackups, + } + if cfg.EnableRotation { + w.maxSize = int64(maxSizeMB) * 1024 * 1024 + } + if w.maxBackups < 0 { + w.maxBackups = 0 + } + if cfg.MaxAgeDays > 0 { + w.maxAge = time.Duration(cfg.MaxAgeDays) * 24 * time.Hour + } + if err := w.open(); err != nil { + return nil, err + } + return w, nil +} + +func (w *rotatingFileWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + if err := w.open(); err != nil { + return 0, err + } + } + if w.maxSize > 0 && w.size > 0 && w.size+int64(len(p)) > w.maxSize { + if err := w.rotate(); err != nil { + return 0, err + } + } + n, err := w.file.Write(p) + w.size += int64(n) + return n, err +} + +func (w *rotatingFileWriter) Sync() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Sync() + closeErr := w.file.Close() + w.file = nil + w.size = 0 + if err != nil { + return err + } + return closeErr +} + +func (w *rotatingFileWriter) open() error { + file, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640) + if err != nil { + return fmt.Errorf("open log file %s: %w", w.path, err) + } + w.file = file + if stat, err := file.Stat(); err == nil { + w.size = stat.Size() + } + return nil +} + +func (w *rotatingFileWriter) rotate() error { + if w.file != nil { + _ = w.file.Close() + w.file = nil + } + if w.maxBackups == 0 { + _ = os.Remove(w.path) + return w.open() + } + for i := w.maxBackups - 1; i >= 1; i-- { + oldPath := fmt.Sprintf("%s.%d", w.path, i) + newPath := fmt.Sprintf("%s.%d", w.path, i+1) + if _, err := os.Stat(oldPath); err == nil { + _ = os.Rename(oldPath, newPath) + } + } + if _, err := os.Stat(w.path); err == nil { + _ = os.Rename(w.path, fmt.Sprintf("%s.1", w.path)) + } + w.pruneByAge() + return w.open() +} + +func (w *rotatingFileWriter) pruneByAge() { + if w.maxAge <= 0 { + return + } + cutoff := time.Now().Add(-w.maxAge) + for i := 1; i <= w.maxBackups; i++ { + path := fmt.Sprintf("%s.%d", w.path, i) + if stat, err := os.Stat(path); err == nil && stat.ModTime().Before(cutoff) { + _ = os.Remove(path) + } + } +} diff --git a/cmd/server/logging_test.go b/cmd/server/logging_test.go new file mode 100644 index 0000000..c096f30 --- /dev/null +++ b/cmd/server/logging_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/ShukeBta/MediaStationGo/internal/config" +) + +func TestProductionLoggerSplitsWarnAndErrorAndDropsInfo(t *testing.T) { + dir := t.TempDir() + cfg := &config.Config{} + cfg.App.DataDir = dir + cfg.Logging.Level = "info" + cfg.Logging.Format = "json" + cfg.Logging.OutputPath = filepath.Join(dir, "logs") + cfg.Logging.EnableRotation = true + cfg.Logging.MaxSizeMB = 1 + cfg.Logging.MaxBackups = 2 + + log, err := newLogger(cfg) + if err != nil { + t.Fatal(err) + } + log.Info("info should be dropped") + log.Warn("warning only", zap.String("kind", "warn")) + log.Error("error only", zap.String("kind", "error")) + _ = log.Sync() + + warnBytes, err := os.ReadFile(filepath.Join(dir, "logs", "warn.log")) + if err != nil { + t.Fatal(err) + } + errorBytes, err := os.ReadFile(filepath.Join(dir, "logs", "error.log")) + if err != nil { + t.Fatal(err) + } + warnLog := string(warnBytes) + errorLog := string(errorBytes) + if strings.Contains(warnLog, "info should be dropped") || strings.Contains(errorLog, "info should be dropped") { + t.Fatal("info log should not be written in production") + } + if !strings.Contains(warnLog, "warning only") || strings.Contains(warnLog, "error only") { + t.Fatalf("warn log not isolated: %s", warnLog) + } + if !strings.Contains(errorLog, "error only") || strings.Contains(errorLog, "warning only") { + t.Fatalf("error log not isolated: %s", errorLog) + } +} + +func TestRotatingFileWriterCapsFileSize(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.log") + writer, err := newRotatingFileWriter(path, config.LoggingConfig{ + EnableRotation: true, + MaxSizeMB: 1, + MaxBackups: 2, + }) + if err != nil { + t.Fatal(err) + } + chunk := strings.Repeat("x", 700*1024) + if _, err := writer.Write([]byte(chunk)); err != nil { + t.Fatal(err) + } + if _, err := writer.Write([]byte(chunk)); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path + ".1"); err != nil { + t.Fatalf("expected rotated backup: %v", err) + } + _ = writer.Sync() +} diff --git a/cmd/server/main.go b/cmd/server/main.go index b45a3c1..3d626db 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,6 +27,7 @@ import ( "github.com/gin-gonic/gin" "go.uber.org/zap" + "go.uber.org/zap/zapcore" "github.com/ShukeBta/MediaStationGo/internal/config" "github.com/ShukeBta/MediaStationGo/internal/database" @@ -315,18 +316,68 @@ func newLogger(cfg *config.Config) (*zap.Logger, error) { if cfg.App.Debug { return zap.NewDevelopment() } - zapCfg := zap.NewProductionConfig() - if level, err := zap.ParseAtomicLevel(strings.TrimSpace(cfg.Logging.Level)); err == nil && cfg.Logging.Level != "" { - zapCfg.Level = level - } + level := productionLogLevel(cfg.Logging.Level) + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + var encoder zapcore.Encoder if strings.EqualFold(strings.TrimSpace(cfg.Logging.Format), "console") { - zapCfg.Encoding = "console" + encoder = zapcore.NewConsoleEncoder(encoderCfg) + } else { + encoder = zapcore.NewJSONEncoder(encoderCfg) } - if out := strings.TrimSpace(cfg.Logging.OutputPath); out != "" { - zapCfg.OutputPaths = append(zapCfg.OutputPaths, out) - zapCfg.ErrorOutputPaths = append(zapCfg.ErrorOutputPaths, out) + cores := []zapcore.Core{ + zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), level), } - return zapCfg.Build() + warnPath, errorPath := logFilePaths(cfg) + if warnPath != "" { + warnWriter, err := newRotatingFileWriter(warnPath, cfg.Logging) + if err != nil { + return nil, err + } + cores = append(cores, zapcore.NewCore(encoder, warnWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { + return lvl == zapcore.WarnLevel && level.Enabled(lvl) + }))) + } + if errorPath != "" { + errorWriter, err := newRotatingFileWriter(errorPath, cfg.Logging) + if err != nil { + return nil, err + } + cores = append(cores, zapcore.NewCore(encoder, errorWriter, zap.LevelEnablerFunc(func(lvl zapcore.Level) bool { + return lvl >= zapcore.ErrorLevel && level.Enabled(lvl) + }))) + } + return zap.New(zapcore.NewTee(cores...), zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel), zap.ErrorOutput(zapcore.Lock(os.Stderr))), nil +} + +func productionLogLevel(raw string) zapcore.Level { + level := zapcore.WarnLevel + raw = strings.TrimSpace(raw) + if raw != "" { + var parsed zapcore.Level + if err := parsed.UnmarshalText([]byte(raw)); err == nil { + level = parsed + } + } + if level < zapcore.WarnLevel { + return zapcore.WarnLevel + } + return level +} + +func logFilePaths(cfg *config.Config) (string, string) { + out := strings.TrimSpace(cfg.Logging.OutputPath) + if strings.EqualFold(out, "stdout") || strings.EqualFold(out, "stderr") { + return "", "" + } + if out == "" { + out = filepath.Join(cfg.App.DataDir, "logs") + } + if ext := filepath.Ext(out); ext != "" { + base := strings.TrimSuffix(out, ext) + return base + ".warn" + ext, base + ".error" + ext + } + return filepath.Join(out, "warn.log"), filepath.Join(out, "error.log") } // getLocalIP returns the first non-loopback IPv4 address of the machine. diff --git a/config.example.yaml b/config.example.yaml index d077cc9..8a4e3b6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -49,11 +49,11 @@ secrets: fanart_tv_api_key: "" logging: - level: info # debug | info | warn | error + level: warn # production clamps file/stdout logs to warn or above format: console # console | json - output_path: "" # empty = stdout - enable_rotation: false - max_size_mb: 100 + output_path: "" # empty = {data_dir}/logs; use stdout/stderr to disable file logs + enable_rotation: true + max_size_mb: 20 max_age_days: 30 max_backups: 10 diff --git a/docker-compose.advanced.yml b/docker-compose.advanced.yml index 3211d78..1a11466 100644 --- a/docker-compose.advanced.yml +++ b/docker-compose.advanced.yml @@ -130,7 +130,12 @@ services: MEDIASTATION_DOWNLOAD_CONTAINER_DIR: ${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads} # 日志级别:debug / info / warn / error。 - MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-info} + MEDIASTATION_LOGGING_LEVEL: ${MEDIASTATION_LOGGING_LEVEL:-warn} + MEDIASTATION_LOGGING_OUTPUT_PATH: ${MEDIASTATION_LOGGING_OUTPUT_PATH:-/data/logs} + MEDIASTATION_LOGGING_ENABLE_ROTATION: ${MEDIASTATION_LOGGING_ENABLE_ROTATION:-true} + MEDIASTATION_LOGGING_MAX_SIZE_MB: ${MEDIASTATION_LOGGING_MAX_SIZE_MB:-20} + MEDIASTATION_LOGGING_MAX_AGE_DAYS: ${MEDIASTATION_LOGGING_MAX_AGE_DAYS:-30} + MEDIASTATION_LOGGING_MAX_BACKUPS: ${MEDIASTATION_LOGGING_MAX_BACKUPS:-10} # 转码配置。留空表示自动/软件转码;硬件加速见下方 Intel/NVIDIA 示例。 MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true} diff --git a/internal/config/config.go b/internal/config/config.go index 962232c..ed5f97c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -255,9 +255,10 @@ func setDefaults(v *viper.Viper) { v.SetDefault("secrets.jwt_secret", "") - v.SetDefault("logging.level", "info") + v.SetDefault("logging.level", "warn") v.SetDefault("logging.format", "console") - v.SetDefault("logging.max_size_mb", 100) + v.SetDefault("logging.enable_rotation", true) + v.SetDefault("logging.max_size_mb", 20) v.SetDefault("logging.max_age_days", 30) v.SetDefault("logging.max_backups", 10) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6c573ce..e3592da 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -34,6 +34,9 @@ func TestLoadDefaults(t *testing.T) { if cfg.Database.Type != "auto" { t.Fatalf("expected default database type auto, got %q", cfg.Database.Type) } + if cfg.Logging.Level != "warn" || !cfg.Logging.EnableRotation || cfg.Logging.MaxSizeMB != 20 { + t.Fatalf("expected warn rotating logs by default, got level=%q rotation=%v max=%d", cfg.Logging.Level, cfg.Logging.EnableRotation, cfg.Logging.MaxSizeMB) + } if cfg.Database.MaxOpenConns != defaultDatabaseMaxOpenConns { t.Fatalf("expected default MaxOpenConns %d, got %d", defaultDatabaseMaxOpenConns, cfg.Database.MaxOpenConns) }