必须同时设置 gin.defaultwriter 和 gin.defaulterrorwriter,否则 panic 堆栈丢失;日志轮转须用 lumberjack.logger 而非 os.openfile;zap 需通过 zapcore.addsync 封装 writesyncer;高频健康检查路径必须 skippaths 避免轮转失效。

直接替换 gin.DefaultWriter 会丢失错误日志
很多人以为只改 gin.DefaultWriter 就能让所有日志进文件,但 Gin 实际用了两个独立的 writer:gin.DefaultWriter 负责 Logger() 中间件的访问日志,gin.DefaultErrorWriter 才管 Recovery() 捕获的 panic 堆栈和错误输出。漏掉后者,线上崩溃时你根本看不到堆栈。
正确做法是同时设置:
gin.DefaultWriter = io.MultiWriter(accessFile, os.Stdout)gin.DefaultErrorWriter = io.MultiWriter(errorFile, os.Stderr)
注意:accessFile 和 errorFile 必须是分别打开的 *os.File,不能共用同一个句柄——否则 panic 日志会混在访问日志里,排查时极难定位。
用 lumberjack.Logger 做轮转,别碰 os.OpenFile 硬写
os.OpenFile 配 os.O_APPEND 只能追加,无法自动切分、压缩或清理旧文件。一旦流量突增,单个日志文件几十 GB 是常态,运维查起来卡死,磁盘也容易爆。
必须用 github.com/natefinch/lumberjack:
- 它不是装饰器,而是实现了
io.WriteSyncer和io.Closer的完整轮转器 -
MaxSize单位是 MB(不是字节),设成100表示每满 100MB 切一个新文件 -
MaxBackups: 7表示最多保留 7 个旧文件,超出的自动删 - 务必设
LocalTime: true,否则归档文件名里的日期是 UTC,和你本地日志时间对不上
示例片段:
lj := &lumberjack.Logger{
Filename: "logs/access.log",
MaxSize: 100,
MaxBackups: 7,
MaxAge: 30,
LocalTime: true,
Compress: true,
}
gin.Use(GinZapLogger(...)) 时,zapcore.Core 要封装成 io.WriteSyncer
Zap 自身不接受裸的 *os.File 或 *lumberjack.Logger,必须先用 zapcore.AddSync 包一层。否则启动报错:panic: invalid WriteSyncer。
常见错误写法:core := zapcore.NewCore(encoder, lumberjackInst, level) —— 这里第二个参数必须是 zapcore.WriteSyncer 类型。
正确链路:
- 创建
lumberjack.Logger实例 - 调用
zapcore.AddSync(lj)得到WriteSyncer - 再传给
zapcore.NewCore
另外,如果要用 JSON 格式,别用 zap.NewProductionEncoderConfig() 默认配置——它的 EncodeLevel 输出的是全大写字符串(如 "ERROR"),某些日志系统(比如 Loki)要求小写("error"),得手动覆盖。
高频健康检查路径必须 SkipPaths,否则轮转失效
哪怕你配好了 lumberjack,如果没过滤 /healthz、/readyz 这类接口,它们每秒打几十次,日志文件会在几分钟内突破 MaxSize 限制,触发频繁轮转,磁盘 I/O 拉满,还可能因并发写导致文件锁争用。
Gin 官方没提供全局 Skip 机制,得自己在中间件里做判断:
- 在
GinZapLogger中加if c.Request.URL.Path == "/healthz" { c.Next(); return } - 或者更稳妥:维护一个
map[string]struct{}存需跳过的路径前缀,用strings.HasPrefix判断 - 千万别用正则匹配路径——每次请求都编译正则,性能开销太大
这个点最容易被忽略:轮转配置看着没问题,但线上跑几天发现磁盘涨得飞快,最后查出来是健康检查日志撑爆的。











