ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Serilog按文件大小滚动日志:代码配置避免磁盘写满与日志丢失

Serilog按文件大小滚动日志:代码配置避免磁盘写满与日志丢失 1. 为什么非要代码里折腾日志滚动而不是用配置文件先说个我实际遇到的场景。公司有个老服务日志一直用的是appsettings.json里配置 Serilog 的方式日常跑着也没啥问题。直到有一天线上磁盘被写满了运维凌晨三点打电话把我叫醒。上机器一看单个日志文件已经撑到 28GB打开文件基本卡死排查问题的时候tail一个几十 GB 的日志文件那体验别提多酸爽了。后来我反思了一下问题的根源不在于 Serilog 不好用而在于配置方式太“死”了。配置文件里写死了日志路径和输出模板但文件大小的控制逻辑、保留策略、滚动时机这些参数在 JSON 里表达起来很别扭。更麻烦的是某些环境变量注入的场景下配置文件的值没法动态计算——比如我想让日志大小根据磁盘剩余空间自动调整或者按部署环境动态设置不同的保留天数配置文件根本做不到。这就是我后来彻底转向“代码配置 Serilog”的根本原因。代码里配日志不是炫技而是为了解决几个配置文件解决不了的刚需动态计算日志文件大小上限根据磁盘容量实时调整区分开发环境和生产环境的日志策略在日志系统初始化时执行自定义逻辑比如清理过期日志、检查目录权限方便做单元测试直接在内存里验证日志行为Serilog本身的配置方式分两种ReadFrom.Configuration()走配置文件或者直接在LoggerConfiguration上用代码链式调用。按指定大小生成日志这个需求用代码配置是最直观的——因为你能精确控制fileSizeLimitBytes这个参数还能在滚动逻辑上做精细调整。这篇文章就围绕着按指定大小生成日志这个具体场景把我用 .NET Core 配 Serilog 文件滚动日志的完整方案、踩过的坑、以及生产环境落地的经验全写出来。不管你是刚接触 Serilog 的新手还是已经被日志文件撑爆磁盘折磨过的老手这篇都能给你省下不少时间。2. Serilog 文件日志的核心概念先分清几个容易混淆的术语2.1 RollingFile 和 File 到底什么区别很多人第一次接触 Serilog 的文件日志会被RollingFile和File这两个 Sink 搞糊涂。我刚开始也绕了一阵子。Serilog.Sinks.File这个包提供的是File扩展方法它的核心职责就是“把日志写到一个文件里”。而RollingFile在早期版本中是一个独立的概念现在已经被整合到File方法里通过参数来控制滚动行为。说白了现在的File方法本身已经支持滚动文件了不需要额外引用RollingFile。在较新版本的 Serilog.Sinks.File 中滚动策略主要是通过rollingInterval按时间滚动和rollOnFileSizeLimit按大小滚动这两个参数来实现的。如果你用的是老版本 Serilog可能需要引Serilog.Sinks.RollingFile这个独立包但那个包已经过时了新项目千万别用。直接Install-Package Serilog.Sinks.File就完事了。2.2 SizeLimitedFile 是什么和 File 有什么关系这里有个更深的坑。Serilog.Sinks.File包内部有个SizeLimitedFile类这个类才是真正实现“按大小滚动”的核心组件。很多人不知道这个类的存在遇到按大小滚动的问题根本无从下手去查源码。SizeLimitedFile做的事情很简单每次写入日志之前检查当前文件大小是否超过了设定阈值如果超过了就把当前文件重命名归档然后新建一个文件继续写。这个过程完全自动不需要人工干预。有一点要注意SizeLimitedFile检查文件大小的时机是在写入之前。也就是说如果你设置的阈值是 10MB单条日志的大小是 100KB那文件实际大小可能达到 10.1MB 才会触发滚动。因为它是“先检查再写入”而不是“先写入再检查”所以会有一定的超出量。这个特性我们后面聊参数设置时会具体展开。2.3 按大小滚动和时间滚动能不能同时用这个问题的答案比较微妙。在 Serilog 中rollingInterval和rollOnFileSizeLimit是可以同时启用的但它们的协作关系值得深入理解。我的理解是两者是“或”的关系不是“与”的关系。也就是说时间到了会滚动文件大小到了也会滚动任何一个条件满足都会触发。但这会带来一个连锁反应——日志文件名中会同时包含时间和序号文件归档时的情况会复杂一些。举个实际例子假设你配置了按天滚动 按 10MB 大小滚动log-20250101-001.txt 第一天的第一个文件写满10MB后滚动 log-20250101-002.txt 第一天的第二个文件继续写 log-20250102-001.txt 第二天了重新从001开始这种混合模式看起来挺美好但在生产环境有个隐患——如果你的日志量很大一天内产生几十个文件归档文件数量会急剧膨胀而且按时间排序和按大小排序的规则是叠加的容易把人搞晕。我的实际建议是大多数 Web 应用场景按时间滚动就够用了但如果是长时间运行的批处理任务或者 Windows 服务日志量比较稳定按大小滚动会更可控。两者都配的情况除非你有特别明确的需求否则我不推荐。3. 按指定大小生成日志的代码实现从零到一完整演示3.1 安装必要的 NuGet 包首先你得确保装了正确的包。我用的是 .NET 8Serilog 目前最新稳定版本是 3.x 系列所以需要dotnet add package Serilog dotnet add package Serilog.Sinks.File dotnet add package Serilog.Extensions.Hosting dotnet add package Serilog.Formatting.Compact简单解释一下每个包的作用Serilog核心库提供 Logger 和 LoggerConfigurationSerilog.Sinks.File文件输出目标我们今天的主角Serilog.Extensions.Hosting让 Serilog 和 .NET Core 的依赖注入、Host 生命周期无缝集成Serilog.Formatting.Compact可选输出 JSON 格式的日志适合日志采集系统如果你是 ASP.NET Core 项目还需要Serilog.AspNetCore这个包它能帮你把 HTTP 请求日志、系统日志统一接入 Serilog。3.2 最基础的按大小滚动配置Log.Logger new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.File( path: logs/app.log, fileSizeLimitBytes: 10 * 1024 * 1024, // 10MB rollOnFileSizeLimit: true, retainedFileCountLimit: 7, // 保留最近7个文件 shared: true, flushToDiskInterval: TimeSpan.FromSeconds(5) ) .CreateLogger();光看这代码似乎没什么但有几个关键参数必须解释清楚不然你会踩大坑。fileSizeLimitBytes这个参数是文件大小的上限单位是字节。我写的是10 * 1024 * 1024就是 10MB。注意这里有个常识性错误很多人会犯——直接用10000000代表 10MB。前面我们说了Serilog 检查大小用字节数10000000字节实际上是 9.54MB不是 10MB。自己算一下10000000 / 1024 / 1024 9.5367431640625 MB所以严格来说想要 10MB 的文件必须写10 * 1024 * 1024。当然如果你的需求是“10,000,000 字节”也没错但要清楚自己在做什么。rollOnFileSizeLimit这个 bool 参数决定了“文件达到上限时是否滚动”。设置为true时一旦当前文件大小超过阈值Serilog 会关闭当前文件流将文件重命名追加序号然后创建一个新文件继续写。设置为false时文件达到上限后直接停止写入日志会丢实际上是丢弃新日志这种情况你肯定不想遇到。retainedFileCountLimit控制最多保留多少个日志文件。默认值是 31但实际场景中很多人不配这个参数。如果不配文件会无限增长最终磁盘仍然会满——这就是我们最初问题的根源。把它设成 7 就是只保留最近 7 个文件最老的文件会被自动清理。shared跨进程共享日志文件。如果你的应用是多个进程写同一个日志文件比如多个 Worker Service 实例这个参数必须设为true否则会出现文件占用冲突。单进程场景保持默认false即可。flushToDiskInterval每隔多长时间把缓冲区里的日志写入磁盘。默认是每写一条就 flush性能较差。设为 5 秒的话日志最多丢 5 秒的数据如果进程崩溃但对磁盘 IO 的压力会小很多。这个参数在生产环境特别重要——我之前遇到过日志量大的时候默认 flush 模式把磁盘 IO 打得满满当当业务响应直接变慢。3.3 带时间和大小双重滚动的进阶版这个版本才是我日常用的“标准配置”比较接近生产环境的实际情况Log.Logger new LoggerConfiguration() .MinimumLevel.Debug() .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithThreadId() .WriteTo.File( path: logs/app-.log, rollingInterval: RollingInterval.Day, fileSizeLimitBytes: 50 * 1024 * 1024, rollOnFileSizeLimit: true, retainedFileCountLimit: 10, outputTemplate: {Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}, shared: true, flushToDiskInterval: TimeSpan.FromSeconds(10) ) .WriteTo.Console() .CreateLogger();注意看path参数里的logs/app-.log中间那个连字符很有讲究。当启用了rollingInterval后Serilog 会在文件名中追加日期形成类似app-20250101.log这样的文件名。如果同时启用了rollOnFileSizeLimit并且一天内有多个文件文件名会变成app-20250101-001.log、app-20250101-002.log这样的格式。有意思的是rollingInterval设成Day后哪怕文件大小没到上限到了第二天零点Serilog 也会主动创建一个新文件。这样做的好处是日志按天分文件查找某一天的日志时直接按文件名过滤就行效率非常高。但这里有个风险点容易踩坑——如果你没设retainedFileCountLimit时间滚动会让你不知不觉积累大量文件。比如一天产生 5 个文件保留 30 天那就是 150 个文件看着不多但文件多了以后日志目录的 inode 会暴涨清理起来也麻烦。所以建议rollingInterval和retainedFileCountLimit永远搭配使用。3.4 动态计算文件大小按磁盘剩余空间自动调整前面提到的“动态计算大小”需求用代码配置就能轻松实现。比如这样public static long CalculateFileSizeLimit() { // 获取日志目录所在磁盘的剩余空间 var driveInfo new DriveInfo(Path.GetPathRoot(Path.Combine(Directory.GetCurrentDirectory(), logs))!); var totalFreeSpace driveInfo.TotalFreeSpace; var totalSize driveInfo.TotalSize; // 如果磁盘剩余空间大于 20GB文件大小限制为 100MB if (totalFreeSpace 20L * 1024 * 1024 * 1024) { return 100L * 1024 * 1024; } // 如果剩余空间在 5GB-20GB 之间文件大小限制为 50MB else if (totalFreeSpace 5L * 1024 * 1024 * 1024) { return 50L * 1024 * 1024; } // 如果磁盘空间告急文件大小限制为 20MB else { return 20L * 1024 * 1024; } } // 使用时 Log.Logger new LoggerConfiguration() .WriteTo.File( path: logs/app.log, fileSizeLimitBytes: CalculateFileSizeLimit(), rollOnFileSizeLimit: true, retainedFileCountLimit: 5, shared: true ) .CreateLogger();这段代码跑了 N 多环境从来没让我失望过。磁盘空间充足时用大文件减少文件数磁盘紧张时自动用小文件控制单文件体积这种自适应策略在写日志时实时计算灵活可靠。这个做法的前提是日志目录所在磁盘和系统盘是同一个盘如果你的日志目录在网络磁盘或独立挂载盘上记得调整DriveInfo的路径参数别搞错盘符导致计算出错误的上限。3.5 这个应用在 Program.cs 里怎么挂接有了配置代码还得让 .NET Core 应用真正用起来。在.NET 6的 Web 项目中Program.cs是入口配置顺序很关键var builder WebApplication.CreateBuilder(args); // 清除默认的日志提供程序 builder.Logging.ClearProviders(); // 配置 Serilog Log.Logger new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .WriteTo.File( path: logs/app-.log, rollingInterval: RollingInterval.Day, fileSizeLimitBytes: 50 * 1024 * 1024, rollOnFileSizeLimit: true, retainedFileCountLimit: 10, shared: true ) .CreateLogger(); builder.Host.UseSerilog(); var app builder.Build(); // ... 中间件配置 ... app.Run();这里有几个容易出问题的点ClearProviders()必须在 Serilog 配置之前调用。如果不调用ASP.NET Core 默认的 Console 调试日志provider 会和 Serilog 同时生效导致日志重复输出。有人问重复输出有啥影响轻则日志量翻倍重则敏感信息重复出现在多个地方排障时看着都有点乱。UseSerilog()必须挂在builder.Host后面。这个扩展方法来自Serilog.AspNetCore包作用是让整个 .NET 日志管道ILoggerT都走 Serilog。如果不挂代码里直接Console.WriteLine输出的日志和 Serilog 的日志就会脱节ILoggerT的日志根本不会写到文件里去。ReadFrom.Configuration(builder.Configuration)要不要用。我之前提到代码配置优先但这里依然建议加上它。因为 .NET Core 生态中有些库会直接读配置里的 Serilog 节点比如 Azure Functions 的集成保持两者联动能少很多兼容性问题。你可以在代码里覆盖配置文件的关键参数这样的话就兼顾了“配置文件管简单项 代码管动态项”的灵活性。还有一个小技巧开发环境可以用appsettings.Development.json把MinimumLevel提到Debug生产环境保持Information。代码里不要写死用配置控制最佳。我见过不少专门为这功能写了 N 多环境判断的代码实际上一个配置项就能解决没必要搞那么复杂。4. 生产环境实战中的关键参数调优与排查思路4.1 一个真实的磁盘写满事故复盘前面的理论说得再多不如复盘一个真实事件有说服力。这个事故直接让我把日志方案重构了一遍。那次事故的日志配置是这样的.WriteTo.File(logs/app.log)就这一行其他的全用默认值。默认值看起来人畜无害但悲剧恰恰藏在这里fileSizeLimitBytes默认是 1GB注意这个值在 Serilog 里是 1,073,741,824 字节不是看到的那样小rollOnFileSizeLimit默认是false这意味着文件达到 1GB 后直接罢工不再写日志retainedFileCountLimit默认是 31保留 31 个文件产生的问题链条是这样的文件写到 1GB 后Serilog 不滚动了新日志全部丢弃。但进程没崩、应用没报错一到日志排查时就发现时间点是断开的——某几个小时的日志直接消失。然后因为rollOnFileSizeLimitfalse文件永远停留在那个 1GB 大小磁盘虽然没爆但日志中心的数据不连续差点让人误判业务故障。这次事故让我养成了一个习惯任何日志配置都必须显式声明fileSizeLimitBytes、rollOnFileSizeLimit、retainedFileCountLimit这三个参数。显式声明的意义在于每个参数的意义变得清晰可查而不是等着找后账时才发现某个默认值坑了自己。4.2 文件大小检查机制与 Io 性能的平衡按大小滚动日志本质上是频繁地检查文件大小。高频写入场景下这个检查操作不能是个重操作不然日志还没来得及写先卡在检查上了。Serilog 对这块做了一个不错的优化它通过维护一个内部的字节计数器来跟踪写入量而不是每次写入都去FileInfo.Length现场查询。这个计数器在文件流打开时初始化每次写入后累加当计数超过设定阈值时触发滚动。这个设计的巧妙之处在于整个大小判断过程完全是内存计算不占用额外的磁盘 IO。只有触发滚动时才会执行重命名和重建文件流的操作。但这也带来一个有意思的小 bug——如果你的日志文件被外部程序修改了比如用编辑器手动改过、或者 Logrotate 挪走了一部分内容Serilog 内部的计数器就失真了。它基于“自打开文件以后我写了多少”来判断而不是“文件现在实际多大”。所以可能出现文件实际大小超过阈值却不触发滚动的情况。解决方案有两个一是让 Serilog 在写入时周期性检查文件长度可以通过自定义 Sink 实现二是从运维层面保证日志文件不被外部程序修改。我用的更多是第二种方式因为第一条毕竟绕不开修改源码。4.3 多进程写同一个日志文件的并发问题容器化部署越来越普及多副本同时写日志的场景很常见。如果你在 Kubernetes 里跑了 3 个 Pod都往同一个挂载卷写同一个日志文件你就得认真处理并发问题了。Serilog 的shared: true参数就是为了这个设计的。这个参数开了之后Serilog 底层用FileShare.ReadWrite打开文件流允许多个进程同时写入。但是有两个明显的坑第一个坑是原子性。shared: true只是保证进程能打开文件不保证写入是原子的。多进程同时写入时日志行可能交错出现类似AAAA和BBBB的乱序交叉。你有很大概率在日志文件里看到一行里混着两个进程的输出碎片。第二个坑是滚动时的竞争问题。文件 A 触发滚动进程 1 把文件重命名了但进程 2 的写入句柄还指向旧文件。此时进程 2 的写入会失败或者写入到已经被重命名的文件里。这个坑最容易在“按大小滚动 多进程共享”这个组合下爆发。我的实际建议是多进程场景下宁可每个进程写自己的文件也不要强求一个文件。比如按实例 ID 做日志目录隔离var instanceId Environment.GetEnvironmentVariable(HOSTNAME) ?? Guid.NewGuid().ToString(N); .WriteTo.File($logs/{instanceId}/app-.log, ...);Kubernetes 里的 Pod 名是唯一的用$HOSTNAME环境变量可以直接拿到 Pod 名。收集日志时用通配符匹配所有 Pod 的日志文件。如果是传统部署多实例那就用机器名加进程 ID。4.4 日志格式化对排查效率的影响按大小滚动解决了文件膨胀但日志内容好不好查又是另一码事。我见过不少团队日志文件切得整整齐齐但内容全是类似信息: 异常这种毫无价值的记录。Serilog 的outputTemplate在代码里就能精细控制。我自己的标准模板是outputTemplate: {Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}时间戳带毫秒和时区信息{Level:u3}日志级别大写三位比如INF、ERR{SourceContext}日志来源比如Microsoft.AspNetCore.Hosting或你的业务类名{Message:lj}日志消息lj表示 JSON 格式但保持原样{Exception}异常堆栈信息如果是给日志采集系统ELK、Loki 等用JSON 格式更好。Serilog 官方有CompactJsonFormatter.WriteTo.File( path: logs/app-.log, fileSizeLimitBytes: 50 * 1024 * 1024, rollOnFileSizeLimit: true, formatter: new CompactJsonFormatter() )JSON 日志的优势是结构化字段清晰采集系统可以直接索引不用再写复杂的解析正则。但缺点是可读性差直接终端cat看着费眼。这个问题没有标准答案取决于你用不用日志采集系统。我个人的方案是开发环境用纯文本模板生产环境用 JSON 格式化器两套配置通过IHostEnvironment来决定代码配置灵活就灵活在这。5. 按大小滚动日志常见问题排查清单收集一下我实际处理过的各种问题整理成一个排查清单朋友们可以直接对照检查。问题现象可能原因解决方案日志文件达到 1GB 后不再写入新日志rollOnFileSizeLimit没设或设为false显式设为true或调大fileSizeLimitBytes日志文件数量无限增长磁盘被打满retainedFileCountLimit设得太大或没设根据保留需求合理设置一般 7-30 够用日志内容出现乱序、交叉启用了shared且多进程写同一文件按实例 ID 分目录不要多进程共享一个文件设置 10MB 结果文件变成了 10.5MB大小检查是写入前判断单条日志会造成超出预留 5%-10% 的余量比如目标 10MB 就设 9.5MB日志时间断开中间缺了几个小时fileSizeLimitBytes达到后滚动失败旧文件句柄被占用检查是否有其他程序占用日志文件句柄如 Logrotate设置了rollingInterval但文件没有按天切忽略了大小时滚动和时间滚动的协调关系确认rollingInterval参数放在 File 方法第一个参数位置排查工具方面我一般用这几个命令直接定位问题# 查看日志目录下的文件数量和总大小 ls -lh logs/ | wc -l du -sh logs/ # 查看单个文件大小 ls -lht logs/ | head -20 # 实时跟踪日志文件的写入情况 tail -f logs/app-20250101.log # 查看当前谁占用了日志文件句柄Linux lsof logs/app-20250101.log这几个命令够用了。最后一条lsof非常实用遇到“文件无法滚动”问题时先看是哪个进程占用了文件句柄对症下药。6. 我用代码方式配置按大小滚动的一些心得体会最后说点实在的感受。我从配置文件转代码配置 Serilog大概花了三天时间适应但之后就一直再用这个方式没有再走回头路。代码配置给我最大的信心在于“一切都在掌控之内”。文件大小限制可以根据磁盘实际情况动态调整保留策略可以精确到天数甚至可以根据环境变量区分开发和生产的行为。这种灵活度是配置文件给不了的。但代码配置也有它的烦恼比较直接的缺点是最小改动也需要修改代码、重新部署。所以我现在采用的是混合模式——基础配置放代码动态可变参数放配置文件两边组合使用。比如代码里只配置fileSizeLimitBytes和rollOnFileSizeLimit这两个核心参数其他像 MinimumLevel 这种环境相关的放到 JSON 里。关于按指定大小生成日志这个需求我的最终建议是一定要显式配置rollOnFileSizeLimit: true、fileSizeLimitBytes、retainedFileCountLimit这三个参数。这三个参数按顺序理解就是——“文件多大时滚动”、“滚动后保留几个”、“超过怎么清理”。把这三个想清楚你的日志文件管理就成功了一大半。下次再遇到有人问 Serilog 怎么按大小生成日志直接把文章链接甩给他省得每次重新解释一遍fileSizeLimitBytes的单位默认是字节、rollOnFileSizeLimit默认是 false 这些老生常谈的坑。写日志看似简单但生产环境的磁盘和历史日志管理一旦出问题代价真不小值得认真对待。
返回列表